formstand 0.9.0 → 0.10.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,6 +1,362 @@
1
1
  # Changelog
2
2
 
3
- ## Unreleased
3
+ ## 0.10.0 — 2026-07-28
4
+
5
+ ### Breaking
6
+
7
+ - **`validateFields` / `validateFieldsAsync` return discriminated results
8
+ instead of booleans** (2026-07 self-review #1). `validateFields(paths)` now
9
+ returns `{ kind: "valid" }`, `{ kind: "invalid", errors }` (the error map
10
+ scoped to the requested paths), or `{ kind: "pending", promise }` on an
11
+ async schema — mirroring `validateField`. The old `boolean |
12
+ Promise<boolean>` union let a truthy in-flight `Promise` silently pass
13
+ `if (form.validateFields(...))` gates the moment a schema gained an async
14
+ refine. `validateFieldsAsync` resolves with the settled result instead of a
15
+ boolean. Migrate `if (ok)` to `if (result.kind === "valid")`.
16
+ - **`FieldFormApi.validateField` must not throw the async-required signal**
17
+ — `useField` no longer catches it and escalates to `validateFieldAsync`.
18
+ The declared contract (return `{ kind: "pending", promise }`) has been the
19
+ library's own behavior since the async-routing rework; custom
20
+ implementations written against the old throwing style should return the
21
+ pending result instead.
22
+
23
+ ### Added
24
+
25
+ - **`pathDepth` option on `createForm`/`useForm`** — a type-level-only,
26
+ per-form typed-path depth budget (in segments): `createForm(schema, {
27
+ initialValues, pathDepth: 12 })` widens the `FieldPath` union so deeper
28
+ paths bind with full inference, threading through every typed surface
29
+ (`Form<TSchema, D>`, `useField`/`useFieldArray`/`useVariantField`, the
30
+ flag/selector hooks, `createFormHooks`, `createFormContext<S, D>`, the
31
+ bound components' `PathsOf`). The runtime ignores it entirely — path APIs
32
+ always walked any depth — and D infers as a literal from the option.
33
+ Motivated by one global store backing many forms, whose leaves sit past
34
+ the default budget. Raising it is a deliberate TypeScript compile-cost
35
+ trade: the union grows with every extra level, and every path-taking call
36
+ in the editor pays for it. `D` must be a number literal; budgets up to 25
37
+ are supported, and the option is CONSTRAINED to the exported `PathDepth`
38
+ union (`0 | 1 | ... | 25`, exactly the decrement table's range) —
39
+ `pathDepth: 26`, `pathDepth: -1`, a widened `number`-typed variable, and
40
+ a UNION value (`cond ? 12 : 9`, which would infer an unusable
41
+ `Form<S, 9 | 12>`) all fail to compile at the call site instead of
42
+ silently misbehaving. The same `D extends PathDepth` constraint is
43
+ enforced on EVERY generic surface carrying a depth parameter — `Form`
44
+ itself, `createFormContext<S, D>`, `persistForm`, the hooks'
45
+ explicit-`D` positions — so `Form<S, number>` and
46
+ `createFormContext<S, 26>()` are compile errors too, not silently
47
+ unbounded budgets.
48
+ `FieldPath<T, D>` itself (the compat surface for direct users, still
49
+ `D extends number`) NORMALIZES any unusable `D` before recursing: a
50
+ widened `number` (an options object built separately), an out-of-range
51
+ literal (26, -1 — outside the decrement table, where the decrement would
52
+ silently no-op), and a finite union (`9 | 12`, or `PathDepth` itself —
53
+ the decrement distributes over unions and would recurse to the union's
54
+ max) all fall back to the default depth instead of silently building the
55
+ enormous ~25-level union (an empirically verified hazard) or
56
+ hard-erroring TS2615 on recursive value types. The default is now the exported
57
+ `DefaultPathDepth` alias — one source of truth replacing the 22 scattered
58
+ `= 9` literals across the typed surfaces. NOT breaking for existing
59
+ code: `D` is appended LAST with a
60
+ default on every generic surface (the hooks' typed overloads
61
+ forward-reference it from the path parameter's constraint, and the
62
+ selector overloads stay at one type parameter), so existing references —
63
+ `Form<Schema>`, explicit instantiations like
64
+ `useFieldArray<typeof schema, "tags">`, `createFormHooks<S, "name">` —
65
+ keep compiling unchanged; type tests pin the arity, the option's
66
+ rejection cases, the non-literal fallback, and both directions of
67
+ `Form<S, 12>` / `Form<S, 9>` non-assignability.
68
+
69
+ ### Changed
70
+
71
+ - **Default typed-path depth raised from 7 to 9 segments**
72
+ (`FieldPath<T, D = 9>`). Real-world stores kept hitting the 7-segment cap
73
+ (the CLI's nested-array output reaches 7 with just three array levels,
74
+ and one more object wrapper fell off the union). Measured typecheck cost
75
+ of the wider default across this repo (library + tests + examples) is
76
+ noise-level. `Form<Schema>` references keep compiling unchanged — the new
77
+ `D` parameter defaults to 9 everywhere.
78
+
79
+ ### Fixed
80
+
81
+ - `resetField` on a path with no counterpart in `initialValues` (an appended
82
+ array row) no longer writes an explicit `undefined` into the values tree —
83
+ a hole that crashed row components and failed schema validation. The value
84
+ is left unchanged (with a console warning), and error/touched state under
85
+ the path still clears (2026-07 self-review #2).
86
+ - `useFieldArray` row ids stay glued to the right rows even when row values
87
+ are `Object.is`-equal (`remove(0)` on two blank rows handed the survivor
88
+ the removed row's id, resurrecting the wrong React subtree). The core now
89
+ records every array op's exact new→old index mapping in an internal
90
+ per-path op log, and id derivation replays that log — so the fix covers
91
+ hook-issued ops, IMPERATIVE ops (`form.arrayRemove(...)`), and multiple
92
+ ops per render batch alike. All hook instances on the same path share one
93
+ id state, so sibling `useFieldArray` hooks can never disagree on row ids.
94
+ Whole-array writes that bypass the ops (`setValue`, `restore`) still
95
+ reconcile by value as before, and replay keeps its exact progress when a
96
+ chain breaks mid-batch (an op followed by a `setValue` reconciles only the
97
+ remaining hop); consecutive log records compose when the ring fills, so
98
+ arbitrarily long op batches stay fully replayable. Exact replay applies to
99
+ forms created by `createForm`/`useForm` — schema-less custom
100
+ `FieldArrayFormApi` implementations reconcile by value (2026-07
101
+ self-review #3, completed by the deferred store-side follow-up).
102
+ - `flattenIssues` no longer crashes on an issue path of `"__proto__"`
103
+ (reachable via `ctx.addIssue` in a `superRefine`): the accumulator read now
104
+ checks own keys instead of walking the prototype chain (2026-07
105
+ self-review #4).
106
+ - `SelectField` renders its empty option whenever the bound `<select>`
107
+ displays `""` (the condition now derives from `selectProps`' own value
108
+ coercion, covering `undefined`, `null`, AND `""`) — an empty-string value
109
+ no longer displays as the first real option while the form holds `""`.
110
+ An options list that supplies its own `""`-valued entry wins: the
111
+ implicit blank option is suppressed so the explicit labelled one shows.
112
+ - `setError`/server errors keyed at `"__proto__"` are no longer silently
113
+ dropped from the merged `errors` map (`mergeErrorChannels` now checks own
114
+ keys, the same hardening `flattenIssues` got).
115
+
116
+ ### Performance
117
+
118
+ - Async-requiring scopes are latched: once a sync `validate` /
119
+ `validateField` / `validateFields` pass discovers a scope needs async
120
+ parsing, later calls skip the doomed sync parse and go straight to the
121
+ async pass (previously every keystroke on an async schema paid a full sync
122
+ parse that always threw) (2026-07 self-review #5). One nuance: after the
123
+ latch, a value that fails an EARLIER sync check in the same scope also
124
+ returns `pending` (settling via the promise) where it previously settled
125
+ `invalid` synchronously — callers on async schemas must handle `pending`
126
+ regardless, since valid values always produced it. Paths that address no
127
+ slot (out-of-range indices) never consult the latch and still settle
128
+ synchronously.
129
+
130
+ ### Internals
131
+
132
+ - The five bound field components share one `FieldShell` (label + control +
133
+ error line, with the checkbox's label-wrapping layout as a variant) and one
134
+ exported `hasFieldError` predicate — the a11y wiring (`useId`, `-error` id,
135
+ `aria-describedby`) now lives in a single place instead of five copies.
136
+ `hasFieldError` is exported for custom UI-kit adapters.
137
+ - `FieldPathArg` is now actually used by the `useField` / `useFieldArray`
138
+ overloads it describes (it was exported dead).
139
+
140
+ ## formstand-cli 0.8.0 — 2026-07-28
141
+
142
+ ### Changed
143
+
144
+ - **The typed-path budget follows the library default, now 9 segments**
145
+ (`FORMSTAND_PATH_DEPTH` 7 → 9, tracking `FieldPath<T, D = 9>` in
146
+ src/core/fieldPath.ts). Bindings whose full path is 8 or 9 segments —
147
+ degraded to TODOs by the budget fix below — bind real controls again;
148
+ 10+-segment paths keep the TODO + stderr warning. In-budget output is
149
+ byte-identical (the three-level nested-array snapshot is unchanged). The
150
+ library's new per-form `pathDepth` option can widen a form past 9; a
151
+ future `--path-depth` flag would pair with it (not implemented — bind
152
+ those paths by hand for now).
153
+ - **The default walker nesting budget is now DERIVED from the path budget**
154
+ (`DEFAULT_MAX_DEPTH = FORMSTAND_PATH_DEPTH + 2` = 11, up from the
155
+ standalone 10; the constant moved to a shared `depth` module both walkers
156
+ and emitters import). The walker must reach one level PAST the path
157
+ budget so a leaf of up to 10 segments (path budget + 1) at default flags
158
+ degrades via the PATH budget — a real, correctly typed subtree with a
159
+ depth TODO — rather than via walker truncation, whose wrong-kind string
160
+ fallback poisoned `initialValues` and failed the consumer's typecheck (a
161
+ nullable number at level 10 blanked to `""` instead of `null`). Leaves
162
+ DEEPER than that (11+ segments at default flags) still truncate to the
163
+ string-kind stand-in; the generated file now always compiles anyway (the
164
+ truncated leaf forces the `as unknown as` initialValues cast) and each
165
+ truncated path gets its own stderr warning — see Fixed below. The
166
+ DeepBoundaryForm playground demo no longer needs its `--max-depth 11`
167
+ override, and its regenerated output is byte-identical. `--max-depth`
168
+ remains as an override and the recursion backstop.
169
+ - **One boundary predicate for every depth decision.** The ~38 hand-written
170
+ `>`/`>=` comparisons against `FORMSTAND_PATH_DEPTH` across the
171
+ single-file and module emitters are replaced by a single exported
172
+ `overDepthBudget(spec, segments)` — spec-aware (it picks the
173
+ at-the-budget vs needs-headroom boundary from the spec's kind) and used
174
+ at every depth decision in both layouts and the CLI warnings, so they
175
+ can no longer drift on where degradation starts. (A second predicate for
176
+ spec-less row paths, `pastRowBudget`, briefly existed but had no call
177
+ sites and disagreed with `overDepthBudget`'s boundary — it has been
178
+ deleted.) `depthTodoLine` is likewise the one production of the TODO
179
+ comment shared by both emitters.
180
+
181
+ ### Fixed
182
+
183
+ - **Emitted bindings now respect formstand's typed `FieldPath` budget**
184
+ (7 segments when this fix landed; now 9 — see Changed above) (found by
185
+ dogfooding: the DeepBoundaryForm playground demo). The
186
+ walkers happily emitted 9-segment paths (`l1.l2....l8.leaf`) that fail
187
+ typecheck in every consumer (TS2820, `FieldPath<T, D = 7>` in
188
+ `src/core/fieldPath.ts`). The emitters now count segments of the FULL bound
189
+ path — dots split, a template hole (`${index}`, `${p0}`) one segment each,
190
+ so an array level spends TWO — and a binding that would exceed the budget
191
+ degrades exactly like other unsupported shapes: compiling output with a
192
+ `{/* TODO: path ... exceeds formstand's typed FieldPath depth (7); bind by
193
+ hand */}` comment plus a stderr warning per degraded path. Container
194
+ recursion stops at the boundary (an object whose own path is at 7 segments
195
+ can only produce over-budget children), the subtree is still materialized
196
+ in the zod schema and `initialValues`, and paths exactly AT the limit —
197
+ including 7-segment nested-array templates like
198
+ `` `teams.${p0}.members.${p1}.phones.${index}` `` — keep binding
199
+ byte-identically to before. Covers both layouts, all three UIs, and
200
+ recursive nested-array row extraction; this is deliberately NOT a walker
201
+ depth clamp (walk-depth is the wrong proxy for segments — a coarse cap
202
+ would have broken the working 3-level nested-array output).
203
+ Follow-up alignment: an at-budget array whose ITEM is non-scalar (an
204
+ array-of-arrays, or array rows holding a tuple/union) now emits the DEPTH
205
+ todo at the row site instead of the generic extract-a-row advice — which
206
+ was unachievable there, since the row path itself is past the budget —
207
+ and `overBudgetFieldPaths` (the stderr warning list) mirrors the emitted
208
+ TODOs exactly, one entry per TODO site, per-FIELD for an array's object
209
+ rows (the emitters degrade those field by field). The mirror is
210
+ LAYOUT-AWARE (see Fixed below): the walk takes the emitting layout's
211
+ degradation frontier, since the two layouts stop descending at different
212
+ places.
213
+ - **`.default()` / `.prefault()` values now land in the generated
214
+ `initialValues`** instead of being ignored (`defaultedNumber: z.number()
215
+ .default(42)` emitted `undefined`, contradicting the README). `fromZod`
216
+ captures the value off the zod def into a new optional `defaultValue` on
217
+ the IR's `SharedSpecProps`, and `emitInitialValues` seeds the field with
218
+ it whenever it is a JSON-serializable primitive matching the field kind
219
+ (string / finite number / boolean / declared enum option). Dates and
220
+ object/array defaults keep the blank behavior. A seeded default satisfies
221
+ `z.input`, so it also counts toward the
222
+ checked-annotation-instead-of-cast decision. Type mode is unchanged — TS
223
+ types can't carry runtime defaults, so `fromType` never sets
224
+ `defaultValue`. The README now states the real rule.
225
+ Capture hardening on review: zod v4's `def.defaultValue` is a GETTER that
226
+ already resolves the user's factory, so the walk only READS it — a
227
+ function-valued result (an older shape storing the factory itself, or a
228
+ factory returning a function) is treated as not capturable rather than
229
+ invoked, which previously executed arbitrary user code at generation
230
+ time (a fixture spy proves the resolved function is never called). The
231
+ getter is read TWICE and the value captured only when both reads
232
+ `Object.is`-agree on a JSON primitive, so a non-deterministic factory
233
+ (`Date.now`, `randomUUID`) can never bake a run-dependent literal into
234
+ byte-deterministic output. And a default is NEVER seeded into a
235
+ todo-fallback spec (`z.custom<T>().default(...)` walks as a string
236
+ stand-in whose kind lies about `T`): seeding there broke the generated
237
+ file's checked `initialValues` annotation — the field keeps its blank
238
+ behavior instead.
239
+ - **Walker truncation can no longer ship a non-compiling file, and is never
240
+ silent.** A leaf past the walker nesting budget (11+ segments at default
241
+ flags) degrades to a string-kind stand-in BEFORE its wrappers unwrap, so
242
+ its kind and flags can be wrong (`z.number().nullable()` at 11 segments
243
+ blanked to `""` in a `number | null` slot) — and the checked
244
+ `initialValues` annotation shipped that as a compile error in the
245
+ consumer's tsc. `blankNeedsCast` now treats a REQUIRED todo-bearing leaf
246
+ (truncated specs are always required-flagged; a required `z.custom`
247
+ fallback hits the same rule) as not input-satisfying, forcing the
248
+ `as unknown as` cast so the generated file always compiles — in both
249
+ layouts, which share the one predicate. Optional/nullable fallbacks keep
250
+ the checked annotation (their `undefined`/`null` blanks genuinely satisfy
251
+ `z.input` — those flags only exist when real wrappers unwrapped). And the
252
+ truncation is mirrored on stderr: the walkers stamp a recognizable marker
253
+ todo (`NESTING_LIMIT_TODO`), `truncatedFieldPaths` collects the truncated
254
+ paths, and the CLI prints
255
+ `warning: path "…" exceeds the walker nesting budget (11); field degraded
256
+ to a placeholder — raise --max-depth or bind by hand` per path.
257
+ - **Depth warnings no longer promise TODOs the chosen layout never emits.**
258
+ `overBudgetFieldPaths` walked one fixed shape while the two layouts stop
259
+ descending at different frontiers: the single-file layout does not descend
260
+ into array-of-array items (generic extract-a-row TODO, no depth TODO
261
+ inside), and the module layout does not recurse into OBJECT fields of an
262
+ array row (generic bind-by-hand TODO) — so for those shapes the stderr
263
+ warnings claimed depth TODOs that weren't in the file (always
264
+ over-promising, never under-reporting). The walk now takes the emitting
265
+ layout's `DepthWarningFrontier` (`depthWarningFrontier("single" |
266
+ "module")`; the single-file frontier is the default, preserving existing
267
+ programmatic behavior), and `warnDegradedBindings` passes the CLI's
268
+ `--layout`. Tests pin warnings-per-TODO equality for both divergent
269
+ shapes in both layouts.
270
+ - **Refused `.default()` captures now warn instead of degrading silently.**
271
+ Every mode that leaves a declared default unseeded is mirrored on stderr:
272
+ the capture guard's refusals (function-valued resolved default,
273
+ non-deterministic two-read disagreement, throwing getter) are recorded on
274
+ the IR as `droppedDefault`, and emit-time refusals (todo-fallback specs,
275
+ kind-mismatched or non-finite values, undeclared enum options, dates,
276
+ container defaults) are detected off the captured `defaultValue` — the
277
+ CLI prints `warning: field "…" has a .default() the CLI could not capture
278
+ (non-primitive, non-deterministic, or degraded field); it starts blank —
279
+ seed it by hand`, one per field. Fields with no default never warn.
280
+
281
+ ## formstand-cli 0.7.0 — 2026-07-12
282
+
283
+ ### Added
284
+
285
+ - **Tuple support** (`z.tuple([...])` and `[A, B]` in type mode). Tuples were
286
+ degrading to a single string field with a TODO; they now generate fixed
287
+ positional controls, each bound at a static numeric-index path (`coord.0`,
288
+ `coord.1`), in both the single-file and module layouts and all three UIs.
289
+ Scalar elements render a real control; a non-scalar element (object / array /
290
+ union / nested tuple) or a variadic rest degrades to a TODO at just that
291
+ position, so the fixed scalar positions still generate. Generated output is
292
+ typechecked against the real library across all backends.
293
+ - **Recursive nested-array extraction in both layouts.** An array nested
294
+ inside an array row used to generate one level (module) or a TODO
295
+ (single-file). It now recurses to arbitrary depth (bounded by `--max-depth`)
296
+ in **both** layouts: `teams[] › members[] › phones[]` generates a full
297
+ component tree, threading each enclosing row's index down as a `p0`, `p1`, …
298
+ prop. In `--layout module` each level is a `Row`/`Rows` pair; in single-file
299
+ each is a child `{Stem}Rows` component with a typed `form` prop, emitted
300
+ above the main component. Array-of-arrays sections extract an inner row
301
+ component too (module). A non-array shape inside a row (nested object /
302
+ union / tuple) still degrades to a TODO. Every generated tree is typechecked
303
+ against the real library across all three UIs.
304
+ - `--max-depth <n>` flag (and a `maxDepth` argument on `fromZod` / `fromType`)
305
+ for the schema/type nesting budget — the number of levels the walkers
306
+ descend before a level degrades to a string + TODO. The default rose from 6
307
+ to **10**, so deeper schemas generate fully. Recursion is still caught
308
+ directly by a seen-set (a self-referential schema → TODO), with the depth
309
+ budget as the backstop for getter-minted schemas; the IR is always finite.
310
+
311
+ ### Fixed
312
+
313
+ - An array whose item is itself non-scalar (an array-of-arrays, or an array of
314
+ tuples/unions) no longer emits an empty `{/* unreachable */}` row in the
315
+ single-file layout — it now emits a clear TODO to extract a row component,
316
+ matching the documented nested-array behavior.
317
+
318
+ ### Docs & examples (no package changes)
319
+
320
+ - Corrected the CLI README / code-generation guide, which claimed several
321
+ already-resolved limitations: `date` fields have emitted real `DateField` /
322
+ date-input bindings since 0.9 (not a text-input TODO), and the module layout
323
+ extracts one real nested-array row level (not a blanket TODO).
324
+ - Schema builder: **paste a zod schema**. An **Import code…** modal takes a
325
+ TypeScript type _or_ a `z.object(...)` schema — by paste or by picking a
326
+ `.ts` file — and generates the form from it. The zod source is evaluated in
327
+ the browser against the bundled zod (the REPL trust model: your own code, in
328
+ your own tab, nothing fetched or sent) and walked by the real `fromZod`, so
329
+ it shares the paste-a-type mode's exact IR → emitters path. Switching the
330
+ dialect swaps in a worked sample while the field is untouched.
331
+
332
+ ## formstand-cli 0.6.2 — 2026-07-10
333
+
334
+ ### Added
335
+
336
+ - Browser-safe programmatic API via the new `formstand-cli/codegen` subpath.
337
+ Everything downstream of the IR — `fromZod`, every emitter
338
+ (`emitPlainForm` / `emitMuiForm` / `emitShadcnForm` / `emitTemplateForm` /
339
+ `emitModuleForm`, `emitZodSchema` / `emitInitialValues`), `joinModuleFiles`,
340
+ `defineTemplate`, `labelFromName` and the casing helpers — is a pure string
341
+ builder with no `fs`/`path` and no TypeScript compiler, so it bundles for the
342
+ browser (the docs Schema-builder generates forms client-side through it). The
343
+ main `formstand-cli` entry still re-exports all of it and adds the
344
+ Node-oriented `fromType` / `defineConfig`.
345
+
346
+ ## formstand-cli 0.6.1 — 2026-07-10
347
+
348
+ ### Added
349
+
350
+ - Custom templates (`--template <file>` or `template:` in the config): a
351
+ `defineTemplate({ name, imports, leaf })` module overrides the per-kind
352
+ field rendering for a UI kit formstand doesn't ship (Mantine, Chakra,
353
+ in-house), inheriting the generated form's scaffold — sections, arrays,
354
+ discriminated unions, submit. Each leaf renderer gets a context of
355
+ JS-expression strings (`bind`, `field`, `label`, `options`), and the same
356
+ renderer works both in the generated wrapper component and inside union
357
+ variant blocks. Unlisted kinds fall back to plain. `--layout single`
358
+ only for now; overrides `--ui`.
359
+
4
360
 
5
361
  ## 0.9.0 — 2026-07-10
6
362
 
package/README.md CHANGED
@@ -85,10 +85,10 @@ const form = createForm(schema, {
85
85
  | `setError(path, errors)` / `setErrors(map)` / `clearErrors(path?)` | the app-owned **server error channel** (`state.serverErrors`) — validation never touches it; `setError` accepts a single string or an array; `clearErrors(path)` clears both channels at the path and its descendants (`clearErrors("")` clears just the root entry; `clearErrors()` clears everything); `setErrors` replaces the whole server channel |
86
86
  | `setMode` / `setReValidateMode` | switch modes at runtime |
87
87
  | `reset(nextInitial?, options?)` | reset to initial; optional partial overrides (shallow-merged for record roots, replaced wholesale otherwise) and `{ keepErrors, keepTouched, keepSubmitCount }` (no `keepDirty` — dirtiness derives from values vs initial, and reset makes them equal) |
88
- | `resetField(path)` | reset one field to its initial value, clearing its (and descendants') error/touched state (dirtiness clears by definition — the value now equals initial) |
88
+ | `resetField(path)` | reset one field to its initial value, clearing its (and descendants') error/touched state (dirtiness clears by definition — the value now equals initial); a path with no initial counterpart (an appended array row) keeps its value and only clears field state |
89
89
  | `adoptValues(values)` | mid-session rebase: replaces `values` + `initialValues` and clears `errors` and in-flight validation flags (`isValidating`/`isValidatingForm` — the rebase disowns in-flight passes), but **preserves** interaction state (`touched`, `submitCount`, `isSubmitting`, `mode`). Use `reset()` for a full wipe |
90
90
  | `updateState(updater)` | atomic multi-field patch; `errors` is derived from `schemaErrors`/`serverErrors`, so patch the channels — the patch type omits `errors` entirely, and a plain-JS `errors` patch is warned about and ignored |
91
- | `validate()` / `validateField(path)` / `validateFields(paths)` | sync validation; on an async schema they transparently start the async pass instead (`validate`/`validateField` return `{ kind: "pending", promise }`, `validateFields` returns the `Promise<boolean>` itself) |
91
+ | `validate()` / `validateField(path)` / `validateFields(paths)` | sync validation; on an async schema all three transparently start the async pass instead and return `{ kind: "pending", promise }` |
92
92
  | `validateAsync()` / `validateFieldAsync(path)` / `validateFieldsAsync(paths)` | async; supports `async .refine` |
93
93
  | `submit(onValid, onInvalid?, { force? })` → `Promise<SubmitResult>` | full submit flow; resolves `{ kind: "valid", data }`, `{ kind: "invalid", errors }` (errored fields are also marked touched), `{ kind: "skipped" }` when another submit is in flight, or `{ kind: "error", error }` when `onValid` throws/rejects — submit resolves instead of rejecting, so `handleSubmit` never produces an unhandled rejection from an `onValid` throw |
94
94
  | `handleSubmit(onValid, onInvalid?)(event?)` | event handler wrapper that calls `preventDefault` |
@@ -108,7 +108,7 @@ const form = createForm(schema, {
108
108
  - `mode: "all"`: validate on every change and blur.
109
109
  - `reValidateMode` (default `"onChange"`) kicks in once `submitCount > 0`.
110
110
 
111
- For schemas with `async .refine`, sync `validate()` / `validateField()` / `validateFields()` no longer throw: they detect the async requirement, start the async pass themselves, and hand you the in-flight work (`{ kind: "pending", promise }` from `validate`/`validateField`; the `Promise<boolean>` itself from `validateFields`). If the *field being validated* is itself sync, `validateField` still settles synchronously even when other fields carry async refines — field validation parses just that field's subschema when it can (see below).
111
+ For schemas with `async .refine`, sync `validate()` / `validateField()` / `validateFields()` don't throw: they detect the async requirement, start the async pass themselves, and hand you the in-flight work as `{ kind: "pending", promise }` (never a bare truthy `Promise` — a boolean gate can't silently pass). The requirement is also remembered per scope, so later sync calls skip straight to the async pass instead of re-paying a doomed sync parse per keystroke. If the *field being validated* is itself sync, `validateField` still settles synchronously even when other fields carry async refines — field validation parses just that field's subschema when it can (see below).
112
112
 
113
113
  ## React hooks
114
114
 
@@ -165,12 +165,13 @@ users.insert(i, item);
165
165
  users.error; // array-level error (e.g. min(1))
166
166
  ```
167
167
 
168
- `fields` IDs are reconciled against item identity each render, so a row's `id`
169
- stays glued to its item across reorders, inserts, and removes — including
170
- mutations made outside this hook (`form.arrayMove`, `setValue`, `restore`, or a
171
- second `useFieldArray` on the same path). Editing a field keeps its row's `id`
172
- (the row updates instead of remounting); a genuinely new item gets a fresh `id`.
173
- IDs reset when the hook's `path` changes.
168
+ `fields` IDs are shared per `(form, path)` — every hook on the same array sees
169
+ the same ids. Array ops (hook-issued or imperative `form.arrayMove` etc.)
170
+ record their exact index mapping and id derivation replays it, so even rows
171
+ with equal values keep the right ids through ops; whole-array writes
172
+ (`setValue`, `restore`) reconcile by item identity with a positional fallback,
173
+ so editing a field keeps its row's `id` (the row updates instead of
174
+ remounting). A genuinely new item gets a fresh `id`.
174
175
 
175
176
  ### `useFormSelector(form, selector)` / `useFormSelectorShallow(form, selector)`
176
177
 
@@ -347,9 +348,12 @@ decided by the **existing container**: arrays take numeric segments as indices,
347
348
  plain objects take any segment as a string key — so a `z.record` keyed `"0"`
348
349
  reads and writes the record key instead of silently becoming an array. Only
349
350
  when the container doesn't exist yet does the segment type pick what's created
350
- (numeric → array, otherwise object). Two limitations: keys containing `.` are
351
- not addressable, and array writes beyond index 100 000 are refused (a typo'd
352
- index must not allocate gigabytes).
351
+ (numeric → array, otherwise object). Three limitations: keys containing `.`
352
+ are not addressable; array writes beyond index 100 000 are refused (a typo'd
353
+ index must not allocate gigabytes); and the typed `FieldPath` union stops at
354
+ 9 segments by default — the runtime walks any depth, and `createForm`'s
355
+ type-level `pathDepth` option widens the union per form (a deliberate
356
+ compile-time trade; see the [typed paths guide](https://scrumrot.github.io/formstand/guide/typed-paths)).
353
357
 
354
358
  ## Subscriptions (non-React)
355
359
 
@@ -408,7 +412,7 @@ Note `submit` still proceeds when the schema is valid even if server errors are
408
412
 
409
413
  ## Field arrays with nested arrays
410
414
 
411
- Both work — `arrayPush("users.0.tags", tag)` mutates the inner array; outer reorders correctly re-key meta for all nested paths. `useFieldArray`'s stable IDs reset when the hook's path changes (so an inner field array inside a reordered outer item gets fresh IDs).
415
+ Both work — `arrayPush("users.0.tags", tag)` mutates the inner array; outer reorders correctly re-key meta for all nested paths. Inner row ids live per `(form, path)` and reconcile by value when the outer index re-seats a different item at the path — pass a `key` from the outer `fields` so each row's whole subtree moves with its item.
412
416
 
413
417
  ## Optimistic UI
414
418