formstand 0.3.0 → 0.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,115 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.4.1 — 2026-07-08
4
+
5
+ ### Fixed
6
+
7
+ - `submit`'s stale-write guard now uses pass ownership (cleared by
8
+ `reset`/`adoptValues`) in addition to the values-reference check — a bare
9
+ `reset()` on a pristine form during an in-flight submit no longer gets
10
+ stale errors and touched marks committed (reference equality couldn't see
11
+ it: reset restores the same `initialValues` reference).
12
+ - `restore()` no longer resurrects in-flight validation flags captured in a
13
+ snapshot (`isValidating`/`isValidatingForm` are transient, owned by live
14
+ passes — a restored copy could stick forever).
15
+ - `focusFirstError`/`focusField` verify focus against the element's own root
16
+ node, fixing a regression that walked past every candidate inside a shadow
17
+ root and reported failure after moving focus to the wrong control.
18
+ - `focusField("")` focuses the form's first focusable control (whole-form
19
+ scope, like its imperative siblings), with the same multi-form
20
+ refuse-to-guess rule as `focusFirstError`.
21
+ - `field.setError("string")` is normalized to an array before reaching
22
+ `FieldFormApi.setError`, shielding pre-0.4 custom implementations typed
23
+ for `readonly string[]`.
24
+
25
+ ### Internal
26
+
27
+ - Validation pass ownership uses unique `Symbol()` tokens — collisions after
28
+ `reset`/`adoptValues` are impossible by construction rather than guarded
29
+ by a never-reset counter invariant.
30
+ - Publish workflow fails fast when the pushed tag doesn't match
31
+ `package.json`'s version; the CLI release checklist pushes the release
32
+ commit, not just the tag.
33
+
34
+ ## 0.4.0 — 2026-07-08
35
+
36
+ ### Breaking
37
+
38
+ - The deprecated `useFormState` / `useFormStateShallow` aliases are removed —
39
+ use `useFormSelector` / `useFormSelectorShallow` (renamed in 0.2.0 because
40
+ React DOM ships its own, deprecated, `useFormState`).
41
+ - Bound components' `path` prop is schema-typed when `form` is a
42
+ `Form<TSchema>` (`PathsOf<F>`): typo'd paths are now compile errors, and
43
+ runtime-built strings need the documented cast. A structural `FieldFormApi`
44
+ form keeps plain `string`.
45
+ - `focusFirstError`'s root-`""` fallback no longer fires under the default
46
+ `document` scope when the page holds more than one `<form>` — "first
47
+ control" would be a guess, so it returns `false`. Pass the form element
48
+ (e.g. via a ref) as `root`.
49
+ - `SubmitResult` gained `{ kind: "error", error }`: when `onValid` throws or
50
+ rejects, `submit` now **resolves** with that result instead of rejecting
51
+ (so `handleSubmit` never leaves an unhandled rejection). Code that relied
52
+ on catching the rejection must check `result.kind === "error"`.
53
+ - `adoptValues` now clears the in-flight validation flags
54
+ (`isValidating` / `isValidatingForm`) along with the errors it already
55
+ cleared — the rebase disowns in-flight passes.
56
+ - `submit` skips its error/touched state writes when the form was rebased
57
+ while validation was in flight: when `values` changed, when `reset` /
58
+ `adoptValues` ran (including a bare `reset()` on a pristine form, where the
59
+ values reference doesn't change), or when a concurrent
60
+ `submit({ force: true })` re-claimed ownership (the LAST submit's writes
61
+ land). `onValid`/`onInvalid` still run and the result still reports the
62
+ outcome.
63
+
64
+ ### Added
65
+
66
+ - `focusField(path, root?)` — imperative focus by path (the `setFocus` of
67
+ react-hook-form), sharing `focusFirstError`'s focusability rules.
68
+ - `emptyValueForSchema(schema)` is exported — the schema-introspection rule
69
+ behind `useField().emptyValue`, alongside its adapter siblings
70
+ `numberToInputText` / `parseNumberText`.
71
+ - `field.setError` (from `useField`) accepts a single string, matching
72
+ `form.setError`. The hook normalizes the shorthand to a one-element array
73
+ before forwarding, so custom `FieldFormApi` implementations typed for
74
+ `readonly string[]` never receive a bare string.
75
+ - `focusField("")` focuses the first focusable control in scope (the
76
+ whole-form `""` semantics of the imperative surface); under the default
77
+ `document` scope it refuses to guess between multiple `<form>`s, like
78
+ `focusFirstError`'s root-`""` fallback.
79
+ - `validateField` / `validateFieldAsync` targeting a path the schema
80
+ provably cannot contain now warn (once per path per form) — protects the
81
+ docs-sanctioned dynamic-path casts from silent always-valid results.
82
+ - Docs: a migrating-from-react-hook-form guide with the full API mapping
83
+ table.
84
+
85
+ ### Fixed
86
+
87
+ - Array ops no longer strand in-flight `isValidating` flags: flags under the
88
+ path are dropped rather than re-keyed (the completing pass clears the
89
+ original key, so a re-keyed flag could never be cleared).
90
+ - In-flight validation passes are owned via unique symbol tokens (unique by
91
+ construction), so a superseded pre-reset validation pass can never collide
92
+ with a post-reset one and clobber its state.
93
+ - `restore(snapshot)` clears the transient in-flight flags (`isValidating` /
94
+ `isValidatingForm`) instead of restoring them — in-flight state is owned
95
+ by live passes, never by snapshots, so a restored flag would stick forever.
96
+ - `focusFirstError` / `focusField` verify focus against the element's own
97
+ root (`getRootNode().activeElement`), so controls inside a shadow root are
98
+ no longer reported as unfocused (`document.activeElement` retargets to the
99
+ shadow host).
100
+ - `validateFieldAsync("")` delegates to the whole-form pass, so its pending
101
+ state lives in `isValidatingForm` instead of `isValidating[""]`.
102
+ - `focusFirstError` / `focusField` report success only when a control
103
+ actually **holds** focus — hidden, disabled, closed-`<dialog>`, and
104
+ focus-refusing matches are passed over for the next candidate in DOM
105
+ order.
106
+ - `dirtyFields()` / `diff()` report an object that diverges only by an
107
+ `undefined`-valued key, agreeing with `useIsDirty`.
108
+ - `SelectField` tolerates duplicate option values (React keys no longer
109
+ collide).
110
+ - `useField` typo errors blame the path argument against the full
111
+ `FieldPath` union instead of blaming the form argument.
112
+
3
113
  ## 0.3.0 — 2026-07-04
4
114
 
5
115
  ### Added
package/README.md CHANGED
@@ -1,5 +1,7 @@
1
1
  # formstand
2
2
 
3
+ [![CI](https://github.com/Scrumrot/formstand/actions/workflows/ci.yml/badge.svg?branch=main)](https://github.com/Scrumrot/formstand/actions/workflows/ci.yml) [![npm version](https://img.shields.io/npm/v/formstand)](https://www.npmjs.com/package/formstand) [![license: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](https://github.com/Scrumrot/formstand/blob/main/LICENSE)
4
+
3
5
  Zod-schema-first form state for React 19, backed by zustand.
4
6
 
5
7
  **[Documentation](https://scrumrot.github.io/formstand/)** · **[Live playground](https://scrumrot.github.io/formstand/examples/)** · **[API reference](https://scrumrot.github.io/formstand/api/)**
@@ -84,11 +86,11 @@ const form = createForm(schema, {
84
86
  | `setMode` / `setReValidateMode` | switch modes at runtime |
85
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) |
86
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) |
87
- | `adoptValues(values)` | mid-session rebase: replaces `values` + `initialValues` and clears `errors`, but **preserves** interaction state (`touched`, `submitCount`, `isSubmitting`, `isValidating`, `mode`). Use `reset()` for a full wipe |
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 |
88
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 |
89
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) |
90
92
  | `validateAsync()` / `validateFieldAsync(path)` / `validateFieldsAsync(paths)` | async; supports `async .refine` |
91
- | `submit(onValid, onInvalid?, { force? })` → `Promise<SubmitResult>` | full submit flow; resolves `{ kind: "valid", data }`, `{ kind: "invalid", errors }` (errored fields are also marked touched), or `{ kind: "skipped" }` when another submit is in flight |
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 |
92
94
  | `handleSubmit(onValid, onInvalid?)(event?)` | event handler wrapper that calls `preventDefault` |
93
95
  | `getField(path)` | typed one-shot value read |
94
96
  | `getFieldState(path)` | typed one-shot read of a field's full slice (value/error/touched/dirty/isValidating) |
@@ -127,7 +129,7 @@ name.dirty;
127
129
  name.isValidating;
128
130
  name.setValue(v);
129
131
  name.setTouched();
130
- name.setError([...]);
132
+ name.setError("taken"); // or ["taken", "reserved"]
131
133
  name.clearError();
132
134
  name.validate();
133
135
  name.validateAsync();
@@ -176,7 +178,7 @@ Selector-style subscription. Use `useFormSelectorShallow` for selectors that ret
176
178
 
177
179
  > Formerly `useFormState` / `useFormStateShallow` — renamed because React DOM
178
180
  > ships its own (deprecated) `useFormState` and auto-imports regularly grabbed
179
- > the wrong one. The old names still work as deprecated aliases.
181
+ > the wrong one. The old names were removed in 0.4.0.
180
182
 
181
183
  ### `useFormError(form)`
182
184
 
@@ -256,6 +258,20 @@ works out of the box. Wire it into the invalid-submit handler:
256
258
  <form onSubmit={form.handleSubmit(onValid, (errors) => focusFirstError(errors))}>
257
259
  ```
258
260
 
261
+ On a page with several forms, pass the form element (e.g. via a ref) as `root`
262
+ so the search stays inside your form — without it the whole document is
263
+ scanned, and a root-`""` error's first-control fallback refuses to guess
264
+ between forms (it returns `false` when the document holds more than one
265
+ `<form>`):
266
+
267
+ ```tsx
268
+ <form ref={formRef} onSubmit={form.handleSubmit(onValid, (errors) => focusFirstError(errors, formRef.current ?? undefined))}>
269
+ ```
270
+
271
+ It returns `true` only when a control actually received focus — disabled or
272
+ hidden controls, controls inside a closed `<dialog>`, and controls that refuse
273
+ focus (e.g. `display: none`) are passed over for the next match.
274
+
259
275
  For custom focus logic, every bound component also takes a `ref` to its input.
260
276
 
261
277
  ## Sharing a form across components
@@ -409,6 +425,12 @@ form.handleSubmit(async (data) => {
409
425
  });
410
426
  ```
411
427
 
428
+ ## Contributing
429
+
430
+ See [CONTRIBUTING.md](./CONTRIBUTING.md) — note the repo has three
431
+ independent npm roots (`.`, `examples/`, `cli/`), and the root test suite
432
+ needs `examples/` installed.
433
+
412
434
  ## License
413
435
 
414
436
  MIT
package/dist/index.cjs CHANGED
@@ -188,6 +188,39 @@ var emptyValueForSchema = (schema) => {
188
188
  };
189
189
  return walk(schema, false);
190
190
  };
191
+ var unwrapForWalk = (s) => {
192
+ if (s instanceof zod.z.ZodOptional || s instanceof zod.z.ZodNullable) {
193
+ return s.unwrap();
194
+ }
195
+ if (s instanceof zod.z.ZodDefault || s instanceof zod.z.ZodPrefault || s instanceof zod.z.ZodCatch || s instanceof zod.z.ZodReadonly || s instanceof zod.z.ZodNonOptional) {
196
+ return s.def.innerType;
197
+ }
198
+ if (s instanceof zod.z.ZodPipe) return s.def.in;
199
+ return null;
200
+ };
201
+ var isLeafSchema = (s) => s instanceof zod.z.ZodString || s instanceof zod.z.ZodNumber || s instanceof zod.z.ZodBoolean || s instanceof zod.z.ZodBigInt || s instanceof zod.z.ZodDate || s instanceof zod.z.ZodEnum || s instanceof zod.z.ZodLiteral || s instanceof zod.z.ZodNull || s instanceof zod.z.ZodUndefined || s instanceof zod.z.ZodVoid || s instanceof zod.z.ZodNaN || s instanceof zod.z.ZodSymbol;
202
+ var SCHEMA_WALK_BUDGET = 64;
203
+ var schemaHasPath = (schema, path) => {
204
+ const walk = (s, segments, budget) => {
205
+ if (budget <= 0) return true;
206
+ const inner = unwrapForWalk(s);
207
+ if (inner !== null) return walk(inner, segments, budget - 1);
208
+ const [head, ...rest] = segments;
209
+ if (head === void 0) return true;
210
+ if (s instanceof zod.z.ZodObject) {
211
+ const shape = s.shape;
212
+ const child = shape[String(head)];
213
+ if (child !== void 0) return walk(child, rest, budget - 1);
214
+ const catchall = s.def.catchall;
215
+ return catchall !== void 0 && !(catchall instanceof zod.z.ZodNever);
216
+ }
217
+ if (s instanceof zod.z.ZodArray) {
218
+ return typeof head === "number" ? walk(s.element, rest, budget - 1) : false;
219
+ }
220
+ return !isLeafSchema(s);
221
+ };
222
+ return walk(schema, parsePath(path), SCHEMA_WALK_BUDGET);
223
+ };
191
224
  var hasChecks = (s) => {
192
225
  const checks = s.def.checks;
193
226
  return checks !== void 0 && checks.length > 0;
@@ -229,17 +262,19 @@ var dirtyPathsOf = (values, initial, prefix) => {
229
262
  if (valuesEqual(values, initial)) return [];
230
263
  if (isPlainObject(values) && isPlainObject(initial)) {
231
264
  const keys = /* @__PURE__ */ new Set([...Object.keys(values), ...Object.keys(initial)]);
232
- return [...keys].flatMap(
265
+ const childPaths = [...keys].flatMap(
233
266
  (k) => dirtyPathsOf(
234
267
  values[k],
235
268
  initial[k],
236
269
  prefix === "" ? k : `${prefix}.${k}`
237
270
  )
238
271
  );
272
+ return childPaths.length > 0 ? childPaths : [prefix];
239
273
  }
240
274
  return [prefix];
241
275
  };
242
276
  var FORM_SEQ_KEY = "__form__";
277
+ var SUBMIT_SEQ_KEY = "__submit__";
243
278
  var stringArraysEqual = (a, b) => a === b || a.length === b.length && a.every((v, i) => v === b[i]);
244
279
  var reuseErrorRefs = (next, prev) => {
245
280
  if (next === prev) return prev;
@@ -359,6 +394,16 @@ var createForm = (schema, options) => {
359
394
  subschemaCache.set(path, sub);
360
395
  return sub;
361
396
  };
397
+ const checkedSchemaPaths = /* @__PURE__ */ new Set();
398
+ const warnMissingSchemaPath = (fn, path) => {
399
+ if (path === "" || checkedSchemaPaths.has(path)) return;
400
+ checkedSchemaPaths.add(path);
401
+ if (!schemaHasPath(schema, path)) {
402
+ console.warn(
403
+ `[formstand] ${fn}("${path}"): path does not exist in the schema \u2014 result will always be valid.`
404
+ );
405
+ }
406
+ };
362
407
  const fieldScopeFor = (path, values) => {
363
408
  const sub = path === "" ? null : subschemaAt(path);
364
409
  if (sub === null) {
@@ -396,6 +441,7 @@ var createForm = (schema, options) => {
396
441
  serverErrors: omitScope(state.serverErrors, [path])
397
442
  });
398
443
  const validateField = (path) => {
444
+ warnMissingSchemaPath("validateField", path);
399
445
  try {
400
446
  const scoped = scopedFieldErrors(path);
401
447
  store.setState((state) => ({
@@ -421,18 +467,18 @@ var createForm = (schema, options) => {
421
467
  };
422
468
  };
423
469
  const sequences = /* @__PURE__ */ new Map();
424
- const nextSeq = (key) => {
425
- const next = (sequences.get(key) ?? 0) + 1;
426
- sequences.set(key, next);
427
- return next;
470
+ const claimPass = (key) => {
471
+ const pass = Symbol(key);
472
+ sequences.set(key, pass);
473
+ return pass;
428
474
  };
429
475
  const validateAsyncOnForm = async () => {
430
- const seq = nextSeq(FORM_SEQ_KEY);
476
+ const pass = claimPass(FORM_SEQ_KEY);
431
477
  const valuesAtStart = store.getState().values;
432
478
  store.setState((state) => ({ ...state, isValidatingForm: true }));
433
479
  const result = await validateAsync(schema, valuesAtStart);
434
480
  const live = store.getState();
435
- const stillOwns = sequences.get(FORM_SEQ_KEY) === seq;
481
+ const stillOwns = sequences.get(FORM_SEQ_KEY) === pass;
436
482
  if (!stillOwns || live.values !== valuesAtStart) {
437
483
  if (stillOwns) {
438
484
  store.setState((state) => ({ ...state, isValidatingForm: false }));
@@ -455,7 +501,7 @@ var createForm = (schema, options) => {
455
501
  const result2 = await validateAsyncOnForm();
456
502
  return result2.kind === "valid";
457
503
  }
458
- const seqs = new Map(paths.map((p) => [p, nextSeq(p)]));
504
+ const passes = new Map(paths.map((p) => [p, claimPass(p)]));
459
505
  const valuesAtStart = store.getState().values;
460
506
  store.setState((state) => ({
461
507
  ...state,
@@ -470,7 +516,9 @@ var createForm = (schema, options) => {
470
516
  (k) => !inScopeOfAny(k, paths)
471
517
  );
472
518
  const live = store.getState();
473
- const stillCurrent = paths.filter((p) => sequences.get(p) === seqs.get(p));
519
+ const stillCurrent = paths.filter(
520
+ (p) => sequences.get(p) === passes.get(p)
521
+ );
474
522
  if (live.values !== valuesAtStart) {
475
523
  if (stillCurrent.length > 0) {
476
524
  store.setState((state) => ({
@@ -504,7 +552,14 @@ var createForm = (schema, options) => {
504
552
  return requestedValid;
505
553
  };
506
554
  const validateFieldAsync = async (path) => {
507
- const seq = nextSeq(path);
555
+ warnMissingSchemaPath("validateFieldAsync", path);
556
+ if (path === "") {
557
+ const result = await validateAsyncOnForm();
558
+ return fieldResult(
559
+ result.kind === "invalid" ? result.errors : emptyErrors
560
+ );
561
+ }
562
+ const pass = claimPass(path);
508
563
  const valuesAtStart = store.getState().values;
509
564
  store.setState((state) => ({
510
565
  ...state,
@@ -512,7 +567,7 @@ var createForm = (schema, options) => {
512
567
  }));
513
568
  const scoped = await scopedFieldErrorsAsync(path, valuesAtStart);
514
569
  const live = store.getState();
515
- const stillOwns = sequences.get(path) === seq;
570
+ const stillOwns = sequences.get(path) === pass;
516
571
  if (!stillOwns || live.values !== valuesAtStart) {
517
572
  if (stillOwns) {
518
573
  store.setState((state) => ({
@@ -563,7 +618,13 @@ var createForm = (schema, options) => {
563
618
  )
564
619
  }),
565
620
  touched: reKeyByArrayPath(state.touched, path, mapper),
566
- isValidating: reKeyByArrayPath(state.isValidating, path, mapper)
621
+ // Drop (don't re-key) in-flight flags under the array: the op changes
622
+ // `values`, so every pass they belong to fails its values-changed
623
+ // guard and never commits — but each pass's cleanup omits its
624
+ // ORIGINAL key, so a re-keyed entry would be orphaned and stick
625
+ // forever. Dropped, the flag is gone now and the cleanup is a
626
+ // harmless no-op.
627
+ isValidating: omitScope(state.isValidating, [path])
567
628
  };
568
629
  });
569
630
  };
@@ -585,33 +646,44 @@ var createForm = (schema, options) => {
585
646
  submitCount: state.submitCount + 1
586
647
  }));
587
648
  try {
588
- const result = await validateAsync(schema, store.getState().values);
649
+ const pass = claimPass(SUBMIT_SEQ_KEY);
650
+ const valuesAtStart = store.getState().values;
651
+ const result = await validateAsync(schema, valuesAtStart);
652
+ const stale = sequences.get(SUBMIT_SEQ_KEY) !== pass || store.getState().values !== valuesAtStart;
589
653
  if (result.kind === "invalid") {
590
- store.setState((state) => {
591
- const committed = commitFullPass(state, result.errors);
592
- return {
593
- ...state,
594
- ...committed,
595
- // Mark every errored field touched so touched-gated error UIs
596
- // show something after the canonical first failed submit. The
597
- // "" key (schema-level refine) has no field to touch.
598
- touched: {
599
- ...state.touched,
600
- ...Object.fromEntries(
601
- Object.keys(committed.errors).filter((k) => k !== "").map((k) => [k, true])
602
- )
603
- }
604
- };
605
- });
606
- const merged = store.getState().errors;
654
+ if (!stale) {
655
+ store.setState((state) => {
656
+ const committed = commitFullPass(state, result.errors);
657
+ return {
658
+ ...state,
659
+ ...committed,
660
+ // Mark every errored field touched so touched-gated error UIs
661
+ // show something after the canonical first failed submit. The
662
+ // "" key (schema-level refine) has no field to touch.
663
+ touched: {
664
+ ...state.touched,
665
+ ...Object.fromEntries(
666
+ Object.keys(committed.errors).filter((k) => k !== "").map((k) => [k, true])
667
+ )
668
+ }
669
+ };
670
+ });
671
+ }
672
+ const merged = stale ? result.errors : store.getState().errors;
607
673
  onInvalid?.(merged);
608
674
  return { kind: "invalid", errors: merged };
609
675
  }
610
- store.setState((state) => ({
611
- ...state,
612
- ...commitFullPass(state, emptyErrors)
613
- }));
614
- await onValid(result.data);
676
+ if (!stale) {
677
+ store.setState((state) => ({
678
+ ...state,
679
+ ...commitFullPass(state, emptyErrors)
680
+ }));
681
+ }
682
+ try {
683
+ await onValid(result.data);
684
+ } catch (error) {
685
+ return { kind: "error", error };
686
+ }
615
687
  return { kind: "valid", data: result.data };
616
688
  } finally {
617
689
  inFlight.count -= 1;
@@ -743,7 +815,12 @@ var createForm = (schema, options) => {
743
815
  ...errorChannels(state, {
744
816
  schemaErrors: emptyErrors,
745
817
  serverErrors: emptyErrors
746
- })
818
+ }),
819
+ // Clearing `sequences` disowns every in-flight pass, including its
820
+ // flag cleanup (the ownership check fails, so it never writes) —
821
+ // clear the flags here too, mirroring reset(), or they'd stick.
822
+ isValidating: emptyBools,
823
+ isValidatingForm: false
747
824
  }));
748
825
  },
749
826
  reset: (nextInitial, resetOptions) => {
@@ -849,7 +926,13 @@ var createForm = (schema, options) => {
849
926
  errors: reuseErrorRefs(
850
927
  mergeErrorChannels(schemaErrors, serverErrors),
851
928
  snap.errors ?? emptyErrors
852
- )
929
+ ),
930
+ // In-flight state is owned by live passes, never by snapshots: a
931
+ // snapshotted flag has no pass left to clear it (the pass that set
932
+ // it cleared its ORIGINAL entry when it settled), so restoring one
933
+ // would stick it forever.
934
+ isValidating: emptyBools,
935
+ isValidatingForm: false
853
936
  };
854
937
  })
855
938
  });
@@ -908,8 +991,6 @@ function useFormSelector(form, selector) {
908
991
  function useFormSelectorShallow(form, selector) {
909
992
  return react$1.useStore(form.store, shallow.useShallow(selector));
910
993
  }
911
- var useFormState = useFormSelector;
912
- var useFormStateShallow = useFormSelectorShallow;
913
994
  var useFormError = (form) => react$1.useStore(
914
995
  form.store,
915
996
  shallow.useShallow((state) => state.errors[""])
@@ -1007,7 +1088,10 @@ function useField(form, pathArg, options) {
1007
1088
  [form, path]
1008
1089
  );
1009
1090
  const setError = react.useCallback(
1010
- (errors) => form.setError(path, errors),
1091
+ // Normalize the string shorthand HERE, before forwarding: custom
1092
+ // FieldFormApi implementations written against the pre-0.4 surface are
1093
+ // typed for readonly string[] and must never receive a bare string.
1094
+ (errors) => form.setError(path, typeof errors === "string" ? [errors] : errors),
1011
1095
  [form, path]
1012
1096
  );
1013
1097
  const clearError = react.useCallback(
@@ -1206,7 +1290,12 @@ var NumberField = ({
1206
1290
  /* @__PURE__ */ jsxRuntime.jsx(ErrorText, { id: errorId, error: field.error })
1207
1291
  ] });
1208
1292
  };
1209
- var CheckboxField = ({ form, path, label, ref }) => {
1293
+ var CheckboxField = ({
1294
+ form,
1295
+ path,
1296
+ label,
1297
+ ref
1298
+ }) => {
1210
1299
  const id = react.useId();
1211
1300
  const errorId = `${id}-error`;
1212
1301
  const field = useField(form, path);
@@ -1250,7 +1339,12 @@ var SelectField = ({
1250
1339
  "aria-describedby": hasError(field.error) ? errorId : void 0,
1251
1340
  children: [
1252
1341
  showEmptyOption ? /* @__PURE__ */ jsxRuntime.jsx("option", { value: "", disabled: true, children: placeholder ?? "" }) : null,
1253
- options.map((opt) => /* @__PURE__ */ jsxRuntime.jsx("option", { value: opt.value, children: opt.label }, opt.value))
1342
+ options.map((opt, index) => (
1343
+ // Index-qualified: option lists may legitimately repeat a value
1344
+ // (e.g. differently-labelled aliases), and bare value keys would
1345
+ // collide.
1346
+ /* @__PURE__ */ jsxRuntime.jsx("option", { value: opt.value, children: opt.label }, `${index}-${opt.value}`)
1347
+ ))
1254
1348
  ]
1255
1349
  }
1256
1350
  ),
@@ -1259,24 +1353,43 @@ var SelectField = ({
1259
1353
  };
1260
1354
 
1261
1355
  // src/react/focusError.ts
1356
+ var focusableControls = (scope) => [
1357
+ ...scope.querySelectorAll(
1358
+ "input[name], select[name], textarea[name]"
1359
+ )
1360
+ ].filter(
1361
+ (el) => !el.matches(':disabled, [type="hidden"]') && el.closest("dialog:not([open])") === null
1362
+ );
1363
+ var holdsFocus = (el) => {
1364
+ const rootNode = el.getRootNode();
1365
+ const active = rootNode instanceof Document || rootNode instanceof ShadowRoot ? rootNode.activeElement : document.activeElement;
1366
+ return active === el;
1367
+ };
1368
+ var focusFirstOf = (candidates) => candidates.some((el) => {
1369
+ el.focus();
1370
+ return holdsFocus(el);
1371
+ });
1372
+ var nameMatchesAny = (el, paths) => {
1373
+ const name = el.getAttribute("name");
1374
+ return name !== null && paths.some((p) => isPathOrChild(name, p));
1375
+ };
1262
1376
  var focusFirstError = (errors, root) => {
1263
1377
  const scope = root ?? document;
1264
1378
  const erroredPaths = Object.keys(errors).filter(
1265
1379
  (k) => k !== "" && (errors[k]?.length ?? 0) > 0
1266
1380
  );
1267
1381
  const hasRootError = (errors[""]?.length ?? 0) > 0;
1268
- const controls = [
1269
- ...scope.querySelectorAll(
1270
- "input[name], select[name], textarea[name]"
1271
- )
1272
- ].filter((el) => !el.matches(':disabled, [type="hidden"]'));
1273
- const fieldMatch = controls.find((el) => {
1274
- const name = el.getAttribute("name");
1275
- return name !== null && erroredPaths.some((k) => isPathOrChild(name, k));
1276
- });
1277
- const target = fieldMatch ?? (hasRootError ? controls[0] : void 0);
1278
- target?.focus();
1279
- return target !== void 0;
1382
+ const controls = focusableControls(scope);
1383
+ const fieldMatches = controls.filter(
1384
+ (el) => nameMatchesAny(el, erroredPaths)
1385
+ );
1386
+ const rootFallbackApplies = hasRootError && (root !== void 0 || document.querySelectorAll("form").length <= 1);
1387
+ const candidates = fieldMatches.length > 0 ? fieldMatches : rootFallbackApplies ? controls : [];
1388
+ return focusFirstOf(candidates);
1389
+ };
1390
+ var focusField = (path, root) => {
1391
+ const controls = focusableControls(root ?? document);
1392
+ return path === "" ? (root !== void 0 || document.querySelectorAll("form").length <= 1) && focusFirstOf(controls) : focusFirstOf(controls.filter((el) => nameMatchesAny(el, [path])));
1280
1393
  };
1281
1394
  var EMPTY_ID_STATE = { items: [], ids: [], counter: 0 };
1282
1395
  var EMPTY_ITEMS = [];
@@ -1390,7 +1503,9 @@ exports.TextField = TextField;
1390
1503
  exports.checkboxProps = checkboxProps;
1391
1504
  exports.createForm = createForm;
1392
1505
  exports.createFormContext = createFormContext;
1506
+ exports.emptyValueForSchema = emptyValueForSchema;
1393
1507
  exports.flattenIssues = flattenIssues;
1508
+ exports.focusField = focusField;
1394
1509
  exports.focusFirstError = focusFirstError;
1395
1510
  exports.getAtPath = getAtPath;
1396
1511
  exports.isAsyncRequiredError = isAsyncRequiredError;
@@ -1408,8 +1523,6 @@ exports.useForm = useForm;
1408
1523
  exports.useFormError = useFormError;
1409
1524
  exports.useFormSelector = useFormSelector;
1410
1525
  exports.useFormSelectorShallow = useFormSelectorShallow;
1411
- exports.useFormState = useFormState;
1412
- exports.useFormStateShallow = useFormStateShallow;
1413
1526
  exports.useIsDirty = useIsDirty;
1414
1527
  exports.useIsSubmitting = useIsSubmitting;
1415
1528
  exports.useIsValid = useIsValid;