formstand 0.25.0 → 0.26.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/dist/index.cjs CHANGED
@@ -212,10 +212,12 @@ var hasOwnSlot = (obj, path) => parsePath(path).reduce(
212
212
  ).ok;
213
213
  var writeIntoArray = (arr, index, rest, value, kindAt, prefix) => {
214
214
  const length = Math.max(arr.length, index + 1);
215
- return Array.from(
216
- { length },
217
- (_, i) => i === index ? writeSegments(arr[i], rest, value, kindAt, [...prefix, index]) : arr[i]
218
- );
215
+ const child = writeSegments(arr[index], rest, value, kindAt, [
216
+ ...prefix,
217
+ index
218
+ ]);
219
+ if (index < arr.length && child === arr[index]) return arr;
220
+ return Array.from({ length }, (_, i) => i === index ? child : arr[i]);
219
221
  };
220
222
  var refusesHugeIndex = (index) => {
221
223
  if (index > MAX_ARRAY_INDEX) {
@@ -247,10 +249,12 @@ var writeSegments = (obj, segments, value, kindAt, prefix) => {
247
249
  return obj;
248
250
  }
249
251
  const key2 = String(head);
250
- return {
251
- ...obj,
252
- [key2]: writeSegments(obj[key2], rest, value, kindAt, [...prefix, key2])
253
- };
252
+ const child = writeSegments(obj[key2], rest, value, kindAt, [
253
+ ...prefix,
254
+ key2
255
+ ]);
256
+ if (child === obj[key2] && Object.hasOwn(obj, key2)) return obj;
257
+ return { ...obj, [key2]: child };
254
258
  }
255
259
  const kind = kindAt?.(prefix.map(String).join(".")) ?? (typeof head === "number" ? "array" : "record");
256
260
  if (kind === "array") {
@@ -298,6 +302,12 @@ var validateAsync = async (schema, values) => {
298
302
  return result.success ? { kind: "valid", data: result.data } : { kind: "invalid", errors: flattenIssues(result.error.issues) };
299
303
  };
300
304
  var isAsyncRequiredError = (e) => e instanceof zod.z.core.$ZodAsyncError || e instanceof Error && /Encountered Promise during synchronous parse/i.test(e.message);
305
+ var reportBackgroundFailure = (error) => {
306
+ console.error(
307
+ "[formstand] a background validation pass threw (an async refinement rejected). Its in-flight flags were released. The error:",
308
+ error
309
+ );
310
+ };
301
311
  var isPathOrChild = (key, path) => key === path || key.startsWith(`${path}.`);
302
312
  var prefixErrorKeys = (errors, path) => path === "" ? errors : Object.fromEntries(
303
313
  Object.entries(errors).map(([k, v]) => [
@@ -323,7 +333,11 @@ var unwrapForWalk = (s) => {
323
333
  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) {
324
334
  return s.def.innerType;
325
335
  }
326
- if (s instanceof zod.z.ZodPipe) return s.def.in;
336
+ if (s instanceof zod.z.ZodPipe) {
337
+ const input = s.def.in;
338
+ return input instanceof zod.z.ZodTransform ? s.def.out : input;
339
+ }
340
+ if (s instanceof zod.z.ZodLazy) return s.def.getter();
327
341
  return null;
328
342
  };
329
343
  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;
@@ -349,14 +363,71 @@ var schemaHasPath = (schema, path) => {
349
363
  };
350
364
  return walk(schema, parsePath(path), SCHEMA_WALK_BUDGET);
351
365
  };
366
+ var schemaAllowsKindChangeAt = (schema, path) => {
367
+ const flexible = (s) => s instanceof zod.z.ZodUnion || s instanceof zod.z.ZodDiscriminatedUnion || s instanceof zod.z.ZodPipe || s instanceof zod.z.ZodAny || s instanceof zod.z.ZodUnknown || s instanceof zod.z.ZodLazy || s instanceof zod.z.ZodCatch || s.def.coerce === true;
368
+ const strip = (s) => {
369
+ if (s instanceof zod.z.ZodOptional || s instanceof zod.z.ZodNullable) {
370
+ return strip(s.unwrap());
371
+ }
372
+ if (s instanceof zod.z.ZodDefault || s instanceof zod.z.ZodPrefault || s instanceof zod.z.ZodReadonly || s instanceof zod.z.ZodNonOptional) {
373
+ return strip(s.def.innerType);
374
+ }
375
+ return s;
376
+ };
377
+ const walk = (s, segments, budget) => {
378
+ if (budget <= 0) return true;
379
+ const node = strip(s);
380
+ if (flexible(node)) return true;
381
+ const [head, ...rest] = segments;
382
+ if (head === void 0) return false;
383
+ if (node instanceof zod.z.ZodObject) {
384
+ const shape = node.shape;
385
+ const child = Object.hasOwn(shape, String(head)) ? shape[String(head)] : void 0;
386
+ return child === void 0 ? true : walk(child, rest, budget - 1);
387
+ }
388
+ if (node instanceof zod.z.ZodArray) {
389
+ return typeof head === "number" ? walk(node.element, rest, budget - 1) : true;
390
+ }
391
+ if (node instanceof zod.z.ZodTuple && typeof head === "number") {
392
+ const item = node.def.items[head];
393
+ return item === void 0 ? true : walk(item, rest, budget - 1);
394
+ }
395
+ if (node instanceof zod.z.ZodRecord) {
396
+ return walk(node.valueType, rest, budget - 1);
397
+ }
398
+ return true;
399
+ };
400
+ return walk(schema, parsePath(path), SCHEMA_WALK_BUDGET);
401
+ };
352
402
  var hasChecks = (s) => {
353
403
  const checks = s.def.checks;
354
404
  return checks !== void 0 && checks.length > 0;
355
405
  };
356
- var fieldSchemaForIntrospection = (schema, path) => {
357
- const childOf = (s, segment) => {
406
+ var optionClaims = (option, discriminator, tag) => {
407
+ const strip = (s) => {
358
408
  const inner = unwrapForWalk(s);
359
- if (inner !== null) return childOf(inner, segment);
409
+ return inner === null ? s : strip(inner);
410
+ };
411
+ const node = strip(option);
412
+ if (!(node instanceof zod.z.ZodObject)) return false;
413
+ const shape = node.shape;
414
+ const at = Object.hasOwn(shape, discriminator) ? shape[discriminator] : void 0;
415
+ if (at === void 0) return false;
416
+ const leaf = strip(at);
417
+ if (leaf instanceof zod.z.ZodLiteral) {
418
+ return leaf.def.values.includes(tag);
419
+ }
420
+ if (leaf instanceof zod.z.ZodEnum) {
421
+ return Object.values(leaf.def.entries).includes(
422
+ tag
423
+ );
424
+ }
425
+ return false;
426
+ };
427
+ var fieldSchemaForIntrospection = (schema, path, values) => {
428
+ const childOf = (s, segment, holder) => {
429
+ const inner = unwrapForWalk(s);
430
+ if (inner !== null) return childOf(inner, segment, holder);
360
431
  if (s instanceof zod.z.ZodObject && typeof segment === "string") {
361
432
  const shape = s.shape;
362
433
  return Object.hasOwn(shape, segment) ? shape[segment] ?? null : null;
@@ -370,19 +441,45 @@ var fieldSchemaForIntrospection = (schema, path) => {
370
441
  if (s instanceof zod.z.ZodRecord) {
371
442
  return s.valueType;
372
443
  }
444
+ if (s instanceof zod.z.ZodIntersection) {
445
+ return childOf(s.def.left, segment, holder) ?? childOf(s.def.right, segment, holder);
446
+ }
373
447
  if (s instanceof zod.z.ZodDiscriminatedUnion || s instanceof zod.z.ZodUnion) {
374
448
  const options = s.def.options;
375
- return options.reduce(
376
- (found, option) => found ?? childOf(option, segment),
449
+ const discriminator = s instanceof zod.z.ZodDiscriminatedUnion ? s.def.discriminator : null;
450
+ const tag = discriminator !== null && typeof holder === "object" && holder !== null ? holder[discriminator] : void 0;
451
+ const live = discriminator === null || tag === void 0 ? void 0 : options.find((option) => optionClaims(option, discriminator, tag));
452
+ const candidates = live === void 0 ? options : [live];
453
+ return candidates.reduce(
454
+ (found, option) => found ?? childOf(option, segment, holder),
377
455
  null
378
456
  );
379
457
  }
380
458
  return null;
381
459
  };
382
- return parsePath(path).reduce(
383
- (current, segment) => current === null ? null : childOf(current, segment),
384
- schema
385
- );
460
+ const segments = parsePath(path);
461
+ return segments.reduce(
462
+ (acc, segment) => acc.s === null ? acc : {
463
+ s: childOf(
464
+ acc.s,
465
+ segment,
466
+ values === void 0 ? void 0 : getAtPath(values, acc.prefix)
467
+ ),
468
+ prefix: acc.prefix === "" ? String(segment) : `${acc.prefix}.${String(segment)}`
469
+ },
470
+ { s: schema, prefix: "" }
471
+ ).s;
472
+ };
473
+ var schemaAcceptsUndefined = (schema) => schema instanceof zod.z.ZodOptional || schema instanceof zod.z.ZodDefault || schema instanceof zod.z.ZodPrefault || schema instanceof zod.z.ZodNullable && schemaAcceptsUndefined(schema.unwrap());
474
+ var isObjectSchemaAt = (schema, path) => {
475
+ const sub = fieldSchemaForIntrospection(schema, path);
476
+ if (sub === null) return true;
477
+ const strip = (s) => {
478
+ const inner = unwrapForWalk(s);
479
+ return inner === null ? s : strip(inner);
480
+ };
481
+ const node = strip(sub);
482
+ return !(node instanceof zod.z.ZodUnion || node instanceof zod.z.ZodDiscriminatedUnion || node instanceof zod.z.ZodRecord || node instanceof zod.z.ZodMap || node instanceof zod.z.ZodSet || node instanceof zod.z.ZodArray || node instanceof zod.z.ZodTuple || isLeafSchema(node));
386
483
  };
387
484
  var containerKindAt = (schema) => (prefix) => {
388
485
  const unwrapAll = (s) => {
@@ -433,7 +530,7 @@ var fieldSchemaAtPath = (schema, path) => parsePath(path).reduce((current, segme
433
530
  }, schema);
434
531
 
435
532
  // src/core/createForm.ts
436
- var shallowFieldEqual = (a, b) => Object.is(a.value, b.value) && a.error === b.error && a.touched === b.touched && a.dirty === b.dirty && a.isValidating === b.isValidating;
533
+ var shallowFieldEqual = (a, b) => Object.is(a.value, b.value) && Object.is(a.initialValue, b.initialValue) && a.error === b.error && a.touched === b.touched && a.dirty === b.dirty && a.isValidating === b.isValidating;
437
534
  var emptyErrors = {};
438
535
  var emptyBools = {};
439
536
  var identityMapper = (n) => n;
@@ -491,7 +588,27 @@ var reuseErrorRefs = (next, prev) => {
491
588
  const sameShape = entries.length === Object.keys(prev).length && entries.every(([k, v]) => entryAt(prev, k) === v);
492
589
  return sameShape ? prev : Object.fromEntries(entries);
493
590
  };
494
- var scopeSettledResult = (result, path, viaSubschema) => result.kind !== "invalid" ? emptyErrors : viaSubschema ? prefixErrorKeys(result.errors, path) : path === "" ? result.errors : pickScope(result.errors, [path]);
591
+ var noScopeErrors = { scoped: emptyErrors, full: null };
592
+ var scopeParse = (result, path, viaSubschema) => {
593
+ const errors = result.kind === "invalid" ? result.errors : emptyErrors;
594
+ return viaSubschema ? { scoped: prefixErrorKeys(errors, path), full: null } : {
595
+ scoped: path === "" ? errors : pickScope(errors, [path]),
596
+ full: errors
597
+ };
598
+ };
599
+ var pruneUnreported = (prev, full) => {
600
+ const entries = Object.entries(prev);
601
+ const kept = entries.filter(([k]) => Object.hasOwn(full, k));
602
+ const unchanged = kept.length === entries.length && kept.every(([k, list]) => full[k] === list);
603
+ return unchanged ? prev : Object.fromEntries(kept.map(([k, list]) => [k, full[k] ?? list]));
604
+ };
605
+ var dropEmptyLists = (map) => {
606
+ const entries = Object.entries(map);
607
+ const kept = entries.filter(
608
+ ([, list]) => list !== void 0 && list.length > 0
609
+ );
610
+ return kept.length === entries.length ? map : Object.fromEntries(kept);
611
+ };
495
612
  var mergeErrorChannels = (schema, server) => {
496
613
  const extra = Object.entries(server).filter(
497
614
  ([k]) => !Object.hasOwn(schema, k)
@@ -601,17 +718,17 @@ var createForm = (schema, options) => {
601
718
  const merged = scopes.reduce(
602
719
  (acc, { path, scope }) => scope.kind === "skip" ? acc : {
603
720
  ...acc,
604
- ...scopeSettledResult(
721
+ ...scopeParse(
605
722
  validateSyncLatched(path, scope.schema, scope.value),
606
723
  path,
607
724
  true
608
- )
725
+ ).scoped
609
726
  },
610
727
  {}
611
728
  );
612
729
  store.setState((state) => ({
613
730
  ...state,
614
- ...commitScopedErrors(state, merged, paths)
731
+ ...commitScopedErrors(state, merged, paths, null)
615
732
  }));
616
733
  return settledFields(merged);
617
734
  }
@@ -620,7 +737,7 @@ var createForm = (schema, options) => {
620
737
  const requestedErrors = pickScope(fullErrors, paths);
621
738
  store.setState((state) => ({
622
739
  ...state,
623
- ...commitScopedErrors(state, requestedErrors, paths)
740
+ ...commitScopedErrors(state, requestedErrors, paths, fullErrors)
624
741
  }));
625
742
  return settledFields(requestedErrors);
626
743
  } catch (e) {
@@ -665,7 +782,7 @@ var createForm = (schema, options) => {
665
782
  const slot = slotAtPath(values, path);
666
783
  return slot.exists ? { kind: "parse", schema: sub, value: slot.value, viaSubschema: true } : { kind: "skip" };
667
784
  };
668
- const parseScopeAsync = async (path, scope) => scope.kind === "skip" ? emptyErrors : scopeSettledResult(
785
+ const parseScopeAsync = async (path, scope) => scope.kind === "skip" ? noScopeErrors : scopeParse(
669
786
  await validateAsync(scope.schema, scope.value),
670
787
  path,
671
788
  scope.viaSubschema
@@ -685,14 +802,18 @@ var createForm = (schema, options) => {
685
802
  };
686
803
  const settledFields = (scoped) => Object.keys(scoped).length === 0 ? { kind: "valid" } : { kind: "invalid", errors: scoped };
687
804
  const settledFormResult = (result) => settledFields(result.kind === "invalid" ? result.errors : emptyErrors);
688
- const commitScopedErrors = (state, scoped, paths) => errorChannels(state, {
805
+ const commitScopedErrors = (state, scoped, paths, full) => errorChannels(state, {
689
806
  schemaErrors: reuseErrorRefs(
690
- mergeScopedErrors(state.schemaErrors, scoped, paths),
807
+ mergeScopedErrors(
808
+ full === null ? state.schemaErrors : pruneUnreported(state.schemaErrors, full),
809
+ scoped,
810
+ paths
811
+ ),
691
812
  state.schemaErrors
692
813
  ),
693
814
  serverErrors: omitScope(state.serverErrors, paths)
694
815
  });
695
- const commitFieldErrors = (state, scoped, path) => path === "" ? commitFullPass(state, scoped) : commitScopedErrors(state, scoped, [path]);
816
+ const commitFieldErrors = (state, parse, path) => path === "" ? commitFullPass(state, parse.scoped) : commitScopedErrors(state, parse.scoped, [path], parse.full);
696
817
  const validateField = (path) => {
697
818
  warnMissingSchemaPath("validateField", path);
698
819
  const scope = fieldScopeFor(path, store.getState().values);
@@ -700,7 +821,7 @@ var createForm = (schema, options) => {
700
821
  return { kind: "pending", promise: validateFieldAsync(path) };
701
822
  }
702
823
  try {
703
- const scoped = scope.kind === "skip" ? emptyErrors : scopeSettledResult(
824
+ const parse = scope.kind === "skip" ? noScopeErrors : scopeParse(
704
825
  validateSyncLatched(
705
826
  scope.viaSubschema ? path : "",
706
827
  scope.schema,
@@ -711,9 +832,9 @@ var createForm = (schema, options) => {
711
832
  );
712
833
  store.setState((state) => ({
713
834
  ...state,
714
- ...commitFieldErrors(state, scoped, path)
835
+ ...commitFieldErrors(state, parse, path)
715
836
  }));
716
- return fieldResult(scoped);
837
+ return fieldResult(parse.scoped);
717
838
  } catch (e) {
718
839
  if (isAsyncRequiredError(e)) {
719
840
  return { kind: "pending", promise: validateFieldAsync(path) };
@@ -723,11 +844,15 @@ var createForm = (schema, options) => {
723
844
  };
724
845
  const snapshotField = (state, path) => {
725
846
  const value = getAtPath(state.values, path);
847
+ const initialValue = getAtPath(state.initialValues, path);
848
+ const error = entryAt(state.errors, path);
726
849
  return {
727
850
  value,
728
- error: entryAt(state.errors, path),
851
+ initialValue,
852
+ error,
853
+ firstError: error?.[0],
729
854
  touched: flagAt(state.touched, path),
730
- dirty: isFieldDirty(value, getAtPath(state.initialValues, path)),
855
+ dirty: isFieldDirty(value, initialValue),
731
856
  isValidating: flagAt(state.isValidating, path)
732
857
  };
733
858
  };
@@ -741,7 +866,14 @@ var createForm = (schema, options) => {
741
866
  const pass = claimPass(FORM_SEQ_KEY);
742
867
  const valuesAtStart = store.getState().values;
743
868
  store.setState((state) => ({ ...state, isValidatingForm: true }));
744
- const result = await validateAsync(schema, valuesAtStart);
869
+ const result = await validateAsync(schema, valuesAtStart).catch(
870
+ (error) => {
871
+ if (sequences.get(FORM_SEQ_KEY) === pass) {
872
+ store.setState((state) => ({ ...state, isValidatingForm: false }));
873
+ }
874
+ throw error;
875
+ }
876
+ );
745
877
  const live = store.getState();
746
878
  const stillOwns = sequences.get(FORM_SEQ_KEY) === pass;
747
879
  if (!stillOwns || live.values !== valuesAtStart) {
@@ -781,12 +913,15 @@ var createForm = (schema, options) => {
781
913
  ...Object.fromEntries(paths.map((p) => [p, true]))
782
914
  }
783
915
  }));
784
- const parsed = allViaSubschema ? {
916
+ const parseBatch = async () => allViaSubschema ? {
785
917
  kind: "slots",
786
918
  byPath: new Map(
787
919
  await Promise.all(
788
920
  scopes.map(
789
- async ({ path, scope }) => [path, await parseScopeAsync(path, scope)]
921
+ async ({ path, scope }) => [
922
+ path,
923
+ (await parseScopeAsync(path, scope)).scoped
924
+ ]
790
925
  )
791
926
  )
792
927
  )
@@ -796,6 +931,18 @@ var createForm = (schema, options) => {
796
931
  (result) => result.kind === "invalid" ? result.errors : emptyErrors
797
932
  )
798
933
  };
934
+ const parsed = await parseBatch().catch((error) => {
935
+ const stillOwned = new Set(
936
+ paths.filter((p) => sequences.get(p) === passes.get(p))
937
+ );
938
+ if (stillOwned.size > 0) {
939
+ store.setState((state) => ({
940
+ ...state,
941
+ isValidating: omitKeys(state.isValidating, stillOwned)
942
+ }));
943
+ }
944
+ throw error;
945
+ });
799
946
  const errorsFor = (path) => {
800
947
  switch (parsed.kind) {
801
948
  case "slots":
@@ -824,7 +971,15 @@ var createForm = (schema, options) => {
824
971
  Object.fromEntries(
825
972
  fresh.flatMap((path) => Object.entries(errorsFor(path)))
826
973
  ),
827
- fresh
974
+ fresh,
975
+ // The prune map describes the values the batch STARTED with.
976
+ // A skip scope (an index into an empty array) still reads as
977
+ // fresh after an unrelated change, so a batch can commit under
978
+ // changed values; pruning with the stale map then dropped
979
+ // errors committed elsewhere in flight (an email error landing
980
+ // while a wizard step with such a path validated). The prune
981
+ // rides only while the whole values reference held.
982
+ parsed.kind === "full" && live.values === valuesAtStart ? parsed.errors : null
828
983
  ),
829
984
  isValidating: omitKeys(state.isValidating, ownedKeys)
830
985
  }));
@@ -845,24 +1000,32 @@ var createForm = (schema, options) => {
845
1000
  ...state,
846
1001
  isValidating: { ...state.isValidating, [path]: true }
847
1002
  }));
848
- const scoped = await parseScopeAsync(path, scope);
849
- const live = store.getState();
850
- const stillOwns = sequences.get(path) === pass;
851
- if (!stillOwns || scopeStale(scope, path, valuesAtStart, live.values)) {
852
- if (stillOwns) {
1003
+ const releaseOwnFlag = () => {
1004
+ if (sequences.get(path) === pass) {
853
1005
  store.setState((state) => ({
854
1006
  ...state,
855
1007
  isValidating: omitKey(state.isValidating, path)
856
1008
  }));
857
1009
  }
858
- return fieldResult(scoped);
1010
+ };
1011
+ const parse = await parseScopeAsync(path, scope).catch(
1012
+ (error) => {
1013
+ releaseOwnFlag();
1014
+ throw error;
1015
+ }
1016
+ );
1017
+ const live = store.getState();
1018
+ const stillOwns = sequences.get(path) === pass;
1019
+ if (!stillOwns || scopeStale(scope, path, valuesAtStart, live.values)) {
1020
+ releaseOwnFlag();
1021
+ return fieldResult(parse.scoped);
859
1022
  }
860
1023
  store.setState((state) => ({
861
1024
  ...state,
862
- ...commitFieldErrors(state, scoped, path),
1025
+ ...commitFieldErrors(state, parse, path),
863
1026
  isValidating: omitKey(state.isValidating, path)
864
1027
  }));
865
- return fieldResult(scoped);
1028
+ return fieldResult(parse.scoped);
866
1029
  };
867
1030
  const applyArrayOp = (path, nextArray, mapper, indexInBounds) => {
868
1031
  const current = getAtPath(store.getState().values, path);
@@ -921,7 +1084,9 @@ var createForm = (schema, options) => {
921
1084
  count: 0,
922
1085
  baseline: false
923
1086
  };
924
- const submit = async (onValid, onInvalid, submitOptions) => {
1087
+ const submit = async (onValid, onInvalidOrOptions, positionalOptions) => {
1088
+ const submitOptions = typeof onInvalidOrOptions === "object" ? onInvalidOrOptions : positionalOptions;
1089
+ const onInvalid = typeof onInvalidOrOptions === "function" ? onInvalidOrOptions : submitOptions?.onInvalid;
925
1090
  if (inFlight.count > 0 && submitOptions?.force !== true) {
926
1091
  return { kind: "skipped" };
927
1092
  }
@@ -962,6 +1127,7 @@ var createForm = (schema, options) => {
962
1127
  try {
963
1128
  onInvalid?.(merged);
964
1129
  } catch (error) {
1130
+ submitOptions?.onError?.(error);
965
1131
  return { kind: "error", error };
966
1132
  }
967
1133
  return { kind: "invalid", errors: merged };
@@ -987,9 +1153,14 @@ var createForm = (schema, options) => {
987
1153
  }
988
1154
  }
989
1155
  };
990
- if (options.validateOnMount === true) {
991
- validate();
992
- }
1156
+ const judgeBaseline = () => {
1157
+ if (options.validateOnMount !== true) return;
1158
+ const result = validate();
1159
+ if (result.kind === "pending") {
1160
+ void result.promise.catch(reportBackgroundFailure);
1161
+ }
1162
+ };
1163
+ judgeBaseline();
993
1164
  return Object.freeze({
994
1165
  schema,
995
1166
  store,
@@ -1004,8 +1175,12 @@ var createForm = (schema, options) => {
1004
1175
  return store.subscribe((state) => {
1005
1176
  const next = snapshotField(state, path);
1006
1177
  if (!shallowFieldEqual(ref.current, next)) {
1178
+ const prev = ref.current;
1007
1179
  ref.current = next;
1008
- listener(next);
1180
+ listener(
1181
+ next,
1182
+ prev
1183
+ );
1009
1184
  }
1010
1185
  });
1011
1186
  },
@@ -1040,10 +1215,12 @@ var createForm = (schema, options) => {
1040
1215
  if (Object.is(getAtPath(currentValues, path), value) && (value !== void 0 || hasOwnSlot(currentValues, path))) {
1041
1216
  return;
1042
1217
  }
1218
+ const next = setAtPath(currentValues, path, value, kindAt);
1219
+ if (next === currentValues) return;
1043
1220
  clearArrayOpsUnder(store, path);
1044
1221
  store.setState((state) => ({
1045
1222
  ...state,
1046
- values: setAtPath(state.values, path, value, kindAt),
1223
+ values: next,
1047
1224
  ...errorChannels(state, {
1048
1225
  serverErrors: releaseSpine(state.serverErrors, path)
1049
1226
  })
@@ -1081,7 +1258,7 @@ var createForm = (schema, options) => {
1081
1258
  },
1082
1259
  setErrors: (errors) => store.setState((state) => ({
1083
1260
  ...state,
1084
- ...errorChannels(state, { serverErrors: errors })
1261
+ ...errorChannels(state, { serverErrors: dropEmptyLists(errors) })
1085
1262
  })),
1086
1263
  addErrors: (errors) => store.setState((state) => {
1087
1264
  const entries = Object.entries(errors);
@@ -1124,11 +1301,11 @@ var createForm = (schema, options) => {
1124
1301
  if (next.values !== state.values) {
1125
1302
  clearArrayOpsUnder(store, "");
1126
1303
  }
1127
- const serverErrors = patch.serverErrors !== void 0 ? patch.serverErrors : next.values !== state.values ? releaseChangedSlices(state.serverErrors, state.values, next.values) : state.serverErrors;
1304
+ const serverErrors = patch.serverErrors !== void 0 ? dropEmptyLists(patch.serverErrors) : next.values !== state.values ? releaseChangedSlices(state.serverErrors, state.values, next.values) : state.serverErrors;
1128
1305
  store.setState(() => ({
1129
1306
  ...next,
1130
1307
  ...errorChannels(state, {
1131
- schemaErrors: next.schemaErrors,
1308
+ schemaErrors: patch.schemaErrors === void 0 ? state.schemaErrors : dropEmptyLists(patch.schemaErrors),
1132
1309
  serverErrors
1133
1310
  })
1134
1311
  }));
@@ -1150,6 +1327,7 @@ var createForm = (schema, options) => {
1150
1327
  isValidating: emptyBools,
1151
1328
  isValidatingForm: false
1152
1329
  }));
1330
+ judgeBaseline();
1153
1331
  },
1154
1332
  reset: (nextInitial, resetOptions) => {
1155
1333
  sequences.clear();
@@ -1174,6 +1352,7 @@ var createForm = (schema, options) => {
1174
1352
  isValidatingForm: false
1175
1353
  };
1176
1354
  });
1355
+ if (resetOptions?.keepErrors !== true) judgeBaseline();
1177
1356
  },
1178
1357
  resetField: (path) => {
1179
1358
  const initialSlot = slotAtPath(
@@ -1237,9 +1416,14 @@ var createForm = (schema, options) => {
1237
1416
  swapIndices(a, b),
1238
1417
  (len) => Number.isInteger(a) && Number.isInteger(b) && a >= 0 && a < len && b >= 0 && b < len
1239
1418
  ),
1240
- handleSubmit: (onValid, onInvalid, opts) => async (event) => {
1419
+ handleSubmit: (onValid, onInvalidOrOptions, positionalOptions) => async (event) => {
1241
1420
  event?.preventDefault();
1242
- const result = await submit(onValid, onInvalid, opts);
1421
+ const opts = typeof onInvalidOrOptions === "object" ? onInvalidOrOptions : positionalOptions;
1422
+ const result = await submit(
1423
+ onValid,
1424
+ onInvalidOrOptions,
1425
+ positionalOptions
1426
+ );
1243
1427
  if (result.kind === "error" && opts?.onError === void 0 && isDevBuild()) {
1244
1428
  console.warn(
1245
1429
  "[formstand] submit handler threw and nothing observed it \u2014 the form shows no feedback. Pass { onError } to handleSubmit (or setError inside your handler):",
@@ -1263,10 +1447,11 @@ var createForm = (schema, options) => {
1263
1447
  },
1264
1448
  snapshot: () => store.getState(),
1265
1449
  restore: (snap) => {
1450
+ sequences.clear();
1266
1451
  clearArrayOpsUnder(store, "");
1267
1452
  store.setState(() => {
1268
- const schemaErrors = snap.schemaErrors ?? emptyErrors;
1269
- const serverErrors = snap.serverErrors ?? emptyErrors;
1453
+ const schemaErrors = dropEmptyLists(snap.schemaErrors ?? emptyErrors);
1454
+ const serverErrors = dropEmptyLists(snap.serverErrors ?? emptyErrors);
1270
1455
  return {
1271
1456
  ...snap,
1272
1457
  schemaErrors,
@@ -1313,11 +1498,13 @@ var createDebouncer = (ms) => {
1313
1498
  const timer = {
1314
1499
  current: null
1315
1500
  };
1501
+ const scheduled = { current: null };
1316
1502
  const cancel = () => {
1317
1503
  if (timer.current !== null) {
1318
1504
  clearTimeout(timer.current);
1319
1505
  timer.current = null;
1320
1506
  }
1507
+ scheduled.current = null;
1321
1508
  };
1322
1509
  const schedule = (run) => {
1323
1510
  cancel();
@@ -1325,37 +1512,50 @@ var createDebouncer = (ms) => {
1325
1512
  run();
1326
1513
  return;
1327
1514
  }
1515
+ scheduled.current = run;
1328
1516
  timer.current = setTimeout(() => {
1329
1517
  timer.current = null;
1518
+ scheduled.current = null;
1330
1519
  run();
1331
1520
  }, ms);
1332
1521
  };
1522
+ const flush = () => {
1523
+ const run = scheduled.current;
1524
+ if (run === null) return;
1525
+ cancel();
1526
+ run();
1527
+ };
1333
1528
  return Object.freeze({
1334
1529
  schedule,
1335
1530
  cancel,
1531
+ flush,
1336
1532
  pending: () => timer.current !== null
1337
1533
  });
1338
1534
  };
1339
1535
 
1340
1536
  // src/core/persist.ts
1341
1537
  var kindOf = (value) => value === null ? "null" : Array.isArray(value) ? "array" : typeof value;
1342
- var conflictsWith = (draft, reference) => {
1343
- if (draft === void 0 || draft === null) return false;
1344
- if (reference === void 0 || reference === null) return false;
1538
+ var joinPath = (base, key) => base === "" ? String(key) : `${base}.${String(key)}`;
1539
+ var conflictPath = (draft, reference, path, flexibleAt) => {
1540
+ if (draft === void 0 || draft === null) return null;
1541
+ if (reference === void 0 || reference === null) return null;
1345
1542
  const draftKind = kindOf(draft);
1346
1543
  const referenceKind = kindOf(reference);
1347
- if (draftKind !== referenceKind) return true;
1544
+ if (draftKind !== referenceKind) return flexibleAt(path) ? null : path;
1348
1545
  if (draftKind === "array") {
1349
1546
  const [d] = draft;
1350
1547
  const [r] = reference;
1351
- return conflictsWith(d, r);
1548
+ return conflictPath(d, r, joinPath(path, 0), flexibleAt);
1352
1549
  }
1353
1550
  if (draftKind === "object") {
1354
1551
  const d = draft;
1355
1552
  const r = reference;
1356
- return Object.keys(d).some((key) => conflictsWith(d[key], r[key]));
1553
+ return Object.keys(d).reduce(
1554
+ (found, key) => found ?? conflictPath(d[key], r[key], joinPath(path, key), flexibleAt),
1555
+ null
1556
+ );
1357
1557
  }
1358
- return false;
1558
+ return null;
1359
1559
  };
1360
1560
  var defaultStorage = () => {
1361
1561
  try {
@@ -1368,7 +1568,49 @@ var persistForm = (form, options) => {
1368
1568
  const storage = options.storage ?? defaultStorage();
1369
1569
  const debounceMs = options.debounceMs ?? 300;
1370
1570
  const apply = options.apply ?? "adopt";
1371
- const applyDraft = (values) => apply === "edit" || apply === "restore" ? form.setValues(values) : form.adoptValues(values);
1571
+ const schema = form.schema;
1572
+ const fillRequiredGaps = (draft, initial, path) => {
1573
+ if (!isPlainObject(draft) || !isPlainObject(initial)) return draft;
1574
+ if (!isObjectSchemaAt(schema, path)) return draft;
1575
+ return Object.keys(initial).reduce(
1576
+ (acc, key) => {
1577
+ const childPath = joinPath(path, key);
1578
+ if (Object.hasOwn(draft, key)) {
1579
+ const next = fillRequiredGaps(draft[key], initial[key], childPath);
1580
+ return Object.is(next, draft[key]) ? acc : { ...acc, [key]: next };
1581
+ }
1582
+ if (initial[key] === void 0) return acc;
1583
+ const sub = fieldSchemaForIntrospection(schema, childPath);
1584
+ return sub !== null && schemaAcceptsUndefined(sub) ? acc : { ...acc, [key]: initial[key] };
1585
+ },
1586
+ draft
1587
+ );
1588
+ };
1589
+ const applying = { current: false };
1590
+ const applyDraft = (values) => {
1591
+ const filled = fillRequiredGaps(
1592
+ values,
1593
+ form.getState().initialValues,
1594
+ ""
1595
+ );
1596
+ applying.current = true;
1597
+ try {
1598
+ if (apply === "edit" || apply === "restore") {
1599
+ form.setValues(filled);
1600
+ } else {
1601
+ form.adoptValues(filled);
1602
+ }
1603
+ } finally {
1604
+ applying.current = false;
1605
+ }
1606
+ };
1607
+ const isCurrent = (values) => {
1608
+ try {
1609
+ return JSON.stringify(values) === JSON.stringify(form.getState().values);
1610
+ } catch {
1611
+ return false;
1612
+ }
1613
+ };
1372
1614
  const referenceShape = () => {
1373
1615
  try {
1374
1616
  return JSON.parse(JSON.stringify(form.getState().initialValues));
@@ -1376,31 +1618,70 @@ var persistForm = (form, options) => {
1376
1618
  return void 0;
1377
1619
  }
1378
1620
  };
1379
- const load = () => {
1621
+ const flexibleAt = (path) => schemaAllowsKindChangeAt(schema, path);
1622
+ const inspect = (skipWhenCurrent) => {
1623
+ const raw = (() => {
1624
+ try {
1625
+ return storage === null ? null : storage.getItem(options.key);
1626
+ } catch {
1627
+ return null;
1628
+ }
1629
+ })();
1630
+ if (raw === null) return { kind: "absent" };
1380
1631
  try {
1381
- const raw = storage === null ? null : storage.getItem(options.key);
1382
- if (raw === null) return false;
1383
1632
  const parsed = JSON.parse(raw);
1384
1633
  const versioned = options.version !== void 0 && typeof parsed === "object" && parsed !== null && "__v" in parsed;
1385
- if (options.version !== void 0 && !versioned) return false;
1634
+ if (options.version !== void 0 && !versioned) {
1635
+ return {
1636
+ kind: "ignored",
1637
+ reason: `it carries no version and this configuration expects version ${String(options.version)}`
1638
+ };
1639
+ }
1386
1640
  if (options.version === void 0 && typeof parsed === "object" && parsed !== null && Object.hasOwn(parsed, "__v") && Object.hasOwn(parsed, "values")) {
1387
- return false;
1641
+ return {
1642
+ kind: "ignored",
1643
+ reason: "it was written with a version and this configuration has none"
1644
+ };
1388
1645
  }
1389
1646
  const wrapper = parsed;
1390
- if (versioned && wrapper.__v !== options.version) return false;
1391
- if (versioned && wrapper.values === void 0) return false;
1647
+ if (versioned && wrapper.__v !== options.version) {
1648
+ return {
1649
+ kind: "ignored",
1650
+ reason: `its version (${String(wrapper.__v)}) is not the configured version (${String(options.version)})`
1651
+ };
1652
+ }
1653
+ if (versioned && wrapper.values === void 0) {
1654
+ return { kind: "ignored", reason: "its version wrapper holds no values" };
1655
+ }
1392
1656
  const values = versioned ? wrapper.values : parsed;
1393
- if (values === null || values === void 0) return false;
1657
+ if (values === null || values === void 0) {
1658
+ return { kind: "ignored", reason: "it holds no values" };
1659
+ }
1394
1660
  const reference = referenceShape();
1395
- if (reference !== void 0 && conflictsWith(values, reference)) {
1396
- return false;
1661
+ const conflict = reference === void 0 ? null : conflictPath(values, reference, "", flexibleAt);
1662
+ if (conflict !== null) {
1663
+ return {
1664
+ kind: "ignored",
1665
+ reason: `its shape conflicts with the form's initial values at "${conflict}" (set the version option when the schema changes)`
1666
+ };
1397
1667
  }
1668
+ if (skipWhenCurrent && isCurrent(values)) return { kind: "applied" };
1398
1669
  applyDraft(values);
1399
- return true;
1670
+ return { kind: "applied" };
1400
1671
  } catch {
1401
- return false;
1672
+ return { kind: "ignored", reason: "it is not valid JSON" };
1673
+ }
1674
+ };
1675
+ const loadWith = (skipWhenCurrent) => {
1676
+ const outcome = inspect(skipWhenCurrent);
1677
+ if (outcome.kind === "ignored" && isDevBuild()) {
1678
+ console.warn(
1679
+ `[formstand] persistForm: ignored the draft under "${options.key}": ${outcome.reason}. The next edit overwrites it.`
1680
+ );
1402
1681
  }
1682
+ return outcome.kind === "applied";
1403
1683
  };
1684
+ const load = () => loadWith(false);
1404
1685
  const writeDraft = (values) => {
1405
1686
  try {
1406
1687
  storage?.setItem(
@@ -1413,24 +1694,29 @@ var persistForm = (form, options) => {
1413
1694
  }
1414
1695
  };
1415
1696
  const writes = createDebouncer(debounceMs);
1416
- const cancelPending = () => writes.cancel();
1417
1697
  if (apply !== "manual") {
1418
- load();
1698
+ loadWith(true);
1419
1699
  }
1700
+ const cleared = { current: false };
1420
1701
  const unsubscribe = form.watchValues((values) => {
1702
+ if (applying.current) return;
1703
+ if (cleared.current && values === form.getState().initialValues) return;
1704
+ cleared.current = false;
1421
1705
  writes.schedule(() => writeDraft(values));
1422
1706
  });
1423
1707
  return Object.freeze({
1424
1708
  load,
1709
+ flush: () => writes.flush(),
1425
1710
  clear: () => {
1426
- cancelPending();
1711
+ writes.cancel();
1712
+ cleared.current = true;
1427
1713
  try {
1428
1714
  storage?.removeItem(options.key);
1429
1715
  } catch {
1430
1716
  }
1431
1717
  },
1432
1718
  dispose: () => {
1433
- cancelPending();
1719
+ writes.flush();
1434
1720
  unsubscribe();
1435
1721
  }
1436
1722
  });
@@ -1442,17 +1728,31 @@ var usePersistForm = (form, options) => {
1442
1728
  latestOptions.current = options;
1443
1729
  });
1444
1730
  const key = options.key;
1731
+ const hasStorage = options.storage !== void 0;
1732
+ const storageProxy = react.useMemo(
1733
+ () => ({
1734
+ getItem: (k) => latestOptions.current.storage?.getItem(k) ?? null,
1735
+ setItem: (k, v) => latestOptions.current.storage?.setItem(k, v),
1736
+ removeItem: (k) => latestOptions.current.storage?.removeItem(k)
1737
+ }),
1738
+ []
1739
+ );
1445
1740
  react.useEffect(() => {
1446
- const handle = persistForm(form, latestOptions.current);
1741
+ const options2 = latestOptions.current;
1742
+ const handle = persistForm(form, {
1743
+ ...options2,
1744
+ ...options2.storage === void 0 ? {} : { storage: storageProxy }
1745
+ });
1447
1746
  inner.current = handle;
1448
1747
  return () => {
1449
1748
  inner.current = null;
1450
1749
  handle.dispose();
1451
1750
  };
1452
- }, [form, key]);
1751
+ }, [form, key, hasStorage, storageProxy]);
1453
1752
  return react.useMemo(
1454
1753
  () => ({
1455
1754
  load: () => inner.current?.load() ?? false,
1755
+ flush: () => inner.current?.flush(),
1456
1756
  clear: () => inner.current?.clear(),
1457
1757
  // dispose stops THIS component's watching early; the effect cleanup
1458
1758
  // still runs its own dispose harmlessly (persistForm's dispose is
@@ -1486,9 +1786,12 @@ var useForm = (schema, options) => {
1486
1786
  var hasFieldError = (error) => error !== void 0 && error.length > 0;
1487
1787
  var ariaInvalid = (field) => hasFieldError(field.error) ? true : void 0;
1488
1788
  var numberToInputText = (value) => value === void 0 || value === null || Number.isNaN(value) ? "" : String(value);
1789
+ var DECIMAL_TEXT = /^[+-]?(\d+\.?\d*|\.\d+)([eE][+-]?\d+)?$/;
1489
1790
  var parseNumberText = (text) => {
1490
- if (text.trim() === "") return { kind: "empty" };
1491
- const parsed = Number(text);
1791
+ const trimmed = text.trim();
1792
+ if (trimmed === "") return { kind: "empty" };
1793
+ if (!DECIMAL_TEXT.test(trimmed)) return { kind: "invalid" };
1794
+ const parsed = Number(trimmed);
1492
1795
  return Number.isFinite(parsed) ? { kind: "number", value: parsed } : { kind: "invalid" };
1493
1796
  };
1494
1797
  var textInputProps = (field) => ({
@@ -1516,11 +1819,17 @@ var numberInputProps = (field) => ({
1516
1819
  onBlur: field.onBlur
1517
1820
  });
1518
1821
  var dateToInputText = (value) => {
1519
- if (value === void 0 || value === null || Number.isNaN(value.getTime())) {
1520
- return "";
1822
+ if (value === void 0 || value === null) return "";
1823
+ if (typeof value === "string") {
1824
+ const text = value;
1825
+ const parsed = parseDateText(text);
1826
+ if (parsed.kind === "date") return dateToInputText(parsed.value);
1827
+ if (/^\d{4}-\d{2}-\d{2}$/.test(text.trim())) return "";
1521
1828
  }
1829
+ const date = value instanceof Date ? value : new Date(value);
1830
+ if (Number.isNaN(date.getTime())) return "";
1522
1831
  const pad = (part) => String(part).padStart(2, "0");
1523
- return `${String(value.getFullYear()).padStart(4, "0")}-${pad(value.getMonth() + 1)}-${pad(value.getDate())}`;
1832
+ return `${String(date.getFullYear()).padStart(4, "0")}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`;
1524
1833
  };
1525
1834
  var parseDateText = (text) => {
1526
1835
  if (text.trim() === "") return { kind: "empty" };
@@ -1600,7 +1909,8 @@ var useMaskedInput = (field, options) => {
1600
1909
  field.setValue(parsed.value);
1601
1910
  return;
1602
1911
  case "empty": {
1603
- const empty = field.clearable || typeof field.initialValue !== "string" ? field.emptyValue : "";
1912
+ const isStringField = typeof field.initialValue === "string" || typeof field.value === "string";
1913
+ const empty = field.clearable || !isStringField ? field.emptyValue : "";
1604
1914
  setEdit({ raw: text, pushed: empty });
1605
1915
  field.setValue(empty);
1606
1916
  return;
@@ -1624,6 +1934,15 @@ function useFormSelectorShallow(form, selector) {
1624
1934
  }
1625
1935
 
1626
1936
  // src/react/useFormSteps.ts
1937
+ var settleOnLiveValues = async (form, fields, round = 0) => {
1938
+ const before = fields.map((path) => form.getField(path));
1939
+ const result = await form.validateFieldsAsync(fields);
1940
+ const changed = fields.some(
1941
+ (path, i) => !Object.is(form.getField(path), before[i])
1942
+ );
1943
+ if (!changed) return result;
1944
+ return round < 3 ? settleOnLiveValues(form, fields, round + 1) : null;
1945
+ };
1627
1946
  var useFormSteps = (form, steps) => {
1628
1947
  const [step, setStep] = react.useState(0);
1629
1948
  const [visited, setVisited] = react.useState(
@@ -1637,7 +1956,8 @@ var useFormSteps = (form, steps) => {
1637
1956
  async (index) => {
1638
1957
  const definition = latest.current[index];
1639
1958
  if (definition === void 0) return true;
1640
- const result = await form.validateFieldsAsync(definition.fields);
1959
+ const result = await settleOnLiveValues(form, definition.fields);
1960
+ if (result === null) return false;
1641
1961
  if (result.kind === "invalid") {
1642
1962
  Object.keys(result.errors).forEach((key) => {
1643
1963
  form.setTouched(key, true);
@@ -1649,45 +1969,49 @@ var useFormSteps = (form, steps) => {
1649
1969
  [form]
1650
1970
  );
1651
1971
  const currentStep = react.useRef(step);
1652
- react.useEffect(() => {
1653
- currentStep.current = step;
1654
- });
1972
+ const navigate = react.useCallback((target) => {
1973
+ currentStep.current = target;
1974
+ setStep(target);
1975
+ }, []);
1655
1976
  const next = react.useCallback(async () => {
1656
- const ok = await validateStep(step);
1977
+ const from = currentStep.current;
1978
+ const ok = await validateStep(from);
1657
1979
  if (!ok) return false;
1658
- if (currentStep.current !== step) return false;
1659
- const target = Math.min(step + 1, latest.current.length - 1);
1660
- setStep(target);
1980
+ if (currentStep.current !== from) return false;
1981
+ const target = Math.max(0, Math.min(from + 1, latest.current.length - 1));
1982
+ navigate(target);
1661
1983
  setVisited((prev) => /* @__PURE__ */ new Set([...prev, target]));
1662
1984
  return true;
1663
- }, [validateStep, step]);
1985
+ }, [validateStep, navigate]);
1664
1986
  const back = react.useCallback(() => {
1665
- setStep((prev) => Math.max(prev - 1, 0));
1666
- }, []);
1987
+ navigate(Math.max(currentStep.current - 1, 0));
1988
+ }, [navigate]);
1667
1989
  const goTo = react.useCallback(
1668
1990
  async (target) => {
1669
1991
  const count = latest.current.length;
1670
- const clamped = Math.max(0, Math.min(target, count - 1));
1671
- if (clamped <= step) {
1672
- setStep(clamped);
1992
+ if (!Number.isFinite(target)) return currentStep.current;
1993
+ const from = currentStep.current;
1994
+ const clamped = Math.max(0, Math.min(Math.trunc(target), count - 1));
1995
+ if (clamped <= from) {
1996
+ navigate(clamped);
1673
1997
  return clamped;
1674
1998
  }
1675
- const walk = async (from) => {
1676
- if (from >= clamped) return clamped;
1677
- return await validateStep(from) ? walk(from + 1) : from;
1999
+ const walk = async (at) => {
2000
+ if (at >= clamped) return clamped;
2001
+ return await validateStep(at) ? walk(at + 1) : at;
1678
2002
  };
1679
- const landed = await walk(step);
1680
- if (currentStep.current !== step) return currentStep.current;
1681
- setStep(landed);
2003
+ const landed = await walk(from);
2004
+ if (currentStep.current !== from) return currentStep.current;
2005
+ navigate(landed);
1682
2006
  setVisited(
1683
2007
  (prev) => /* @__PURE__ */ new Set([
1684
2008
  ...prev,
1685
- ...Array.from({ length: landed - step + 1 }, (_, i) => step + i)
2009
+ ...Array.from({ length: landed - from + 1 }, (_, i) => from + i)
1686
2010
  ])
1687
2011
  );
1688
2012
  return landed;
1689
2013
  },
1690
- [validateStep, step]
2014
+ [validateStep, navigate]
1691
2015
  );
1692
2016
  const errorFlags = useFormSelectorShallow(
1693
2017
  form,
@@ -1740,8 +2064,7 @@ var useFormAction = (form, onValid, options) => {
1740
2064
  const { onValid: handler, options: opts } = latest.current;
1741
2065
  const result = await form.submit(
1742
2066
  (data) => handler(data, formData),
1743
- opts?.onInvalid,
1744
- opts?.onError === void 0 ? void 0 : { onError: opts.onError }
2067
+ opts ?? {}
1745
2068
  );
1746
2069
  warnUnobserved(result, opts, "useFormAction");
1747
2070
  },
@@ -1757,8 +2080,7 @@ var useFormActionState = (form, action, initialState, options) => {
1757
2080
  async (data) => {
1758
2081
  nextRef.current = await action(prev, data, formData);
1759
2082
  },
1760
- options?.onInvalid,
1761
- options?.onError === void 0 ? void 0 : { onError: options.onError }
2083
+ options ?? {}
1762
2084
  );
1763
2085
  warnUnobserved(result, options, "useFormActionState");
1764
2086
  return nextRef.current;
@@ -1782,6 +2104,7 @@ var createFormContext = () => {
1782
2104
  return { Provider, useFormContext };
1783
2105
  };
1784
2106
  function useField(form, pathArg, options) {
2107
+ const formSchema = form.schema;
1785
2108
  const slice = react$1.useStore(
1786
2109
  form.store,
1787
2110
  shallow.useShallow((state) => {
@@ -1797,25 +2120,26 @@ function useField(form, pathArg, options) {
1797
2120
  error: entryAt(state.errors, p),
1798
2121
  touched: flagAt(state.touched, p),
1799
2122
  dirty: isFieldDirty(value, initialValue2),
1800
- isValidating: flagAt(state.isValidating, p)
2123
+ isValidating: flagAt(state.isValidating, p),
2124
+ // Resolved inside the selector so a discriminant change re-reads
2125
+ // the ACTIVE variant's wrapper for a key the variants share; the
2126
+ // schema node is a stable reference, so the shallow compare holds.
2127
+ schema: formSchema === void 0 ? null : fieldSchemaForIntrospection(formSchema, p, state.values)
1801
2128
  };
1802
2129
  })
1803
2130
  );
1804
2131
  const path = slice.path;
1805
- const formSchema = form.schema;
2132
+ const sub = slice.schema;
1806
2133
  const initialValue = slice.initialValue;
1807
2134
  const { emptyValue, clearable } = react.useMemo(() => {
1808
- if (formSchema !== void 0) {
1809
- const sub = fieldSchemaForIntrospection(formSchema, path);
1810
- if (sub !== null) {
1811
- return {
1812
- emptyValue: emptyValueForSchema(sub),
1813
- clearable: isClearableSchema(sub)
1814
- };
1815
- }
2135
+ if (sub !== null) {
2136
+ return {
2137
+ emptyValue: emptyValueForSchema(sub),
2138
+ clearable: isClearableSchema(sub)
2139
+ };
1816
2140
  }
1817
2141
  return initialValue === null ? { emptyValue: null, clearable: true } : { emptyValue: void 0, clearable: false };
1818
- }, [formSchema, path, initialValue]);
2142
+ }, [sub, initialValue]);
1819
2143
  const debounceMs = options?.debounceMs;
1820
2144
  const debouncerRef = react.useRef(null);
1821
2145
  react.useEffect(() => {
@@ -1824,6 +2148,12 @@ function useField(form, pathArg, options) {
1824
2148
  debouncerRef.current = null;
1825
2149
  };
1826
2150
  }, [form, path, debounceMs]);
2151
+ const validateNow = react.useCallback(() => {
2152
+ const result = form.validateField(path);
2153
+ if (result.kind === "pending") {
2154
+ void result.promise.catch(reportBackgroundFailure);
2155
+ }
2156
+ }, [form, path]);
1827
2157
  const triggerValidate = react.useCallback(() => {
1828
2158
  if (debounceMs !== void 0 && debounceMs > 0) {
1829
2159
  const debouncer = debouncerRef.current ?? createDebouncer(debounceMs);
@@ -1835,12 +2165,12 @@ function useField(form, pathArg, options) {
1835
2165
  const rebased = state.values !== scheduledValues && state.values === state.initialValues;
1836
2166
  if (rebased) return;
1837
2167
  if (!Object.is(getAtPath(state.values, path), scheduled)) return;
1838
- void form.validateFieldAsync(path);
2168
+ void form.validateFieldAsync(path).catch(reportBackgroundFailure);
1839
2169
  });
1840
2170
  return;
1841
2171
  }
1842
- form.validateField(path);
1843
- }, [form, path, debounceMs]);
2172
+ validateNow();
2173
+ }, [form, path, debounceMs, validateNow]);
1844
2174
  const setValue = react.useCallback(
1845
2175
  (value) => {
1846
2176
  const before = form.store.getState();
@@ -1889,9 +2219,10 @@ function useField(form, pathArg, options) {
1889
2219
  state.submitCount > 0,
1890
2220
  true
1891
2221
  )) {
1892
- triggerValidate();
2222
+ debouncerRef.current?.cancel();
2223
+ validateNow();
1893
2224
  }
1894
- }, [form, path, triggerValidate]);
2225
+ }, [form, path, validateNow]);
1895
2226
  return react.useMemo(
1896
2227
  () => ({
1897
2228
  path: slice.path,
@@ -1918,10 +2249,11 @@ function useField(form, pathArg, options) {
1918
2249
  }
1919
2250
 
1920
2251
  // src/react/useVariantField.ts
1921
- function useVariantField(form, unionPath, field) {
2252
+ function useVariantField(form, unionPath, field, options) {
1922
2253
  return useField(
1923
2254
  form,
1924
- `${unionPath}.${field}`
2255
+ `${unionPath}.${field}`,
2256
+ options
1925
2257
  );
1926
2258
  }
1927
2259
  var EMPTY_ITEMS = [];
@@ -2084,6 +2416,12 @@ function useFieldArray(form, pathArg) {
2084
2416
  const items = slice.items;
2085
2417
  const error = slice.error;
2086
2418
  const ids = deriveIds(form.store, path, items).ids;
2419
+ const validateNow = react.useCallback(() => {
2420
+ const result = form.validateField?.(path);
2421
+ if (result?.kind === "pending") {
2422
+ void result.promise.catch(reportBackgroundFailure);
2423
+ }
2424
+ }, [form, path]);
2087
2425
  const revalidate = react.useCallback(() => {
2088
2426
  const state = form.store.getState();
2089
2427
  if (shouldValidateOn(
@@ -2093,9 +2431,9 @@ function useFieldArray(form, pathArg) {
2093
2431
  state.submitCount > 0,
2094
2432
  flagAt(state.touched, path)
2095
2433
  )) {
2096
- form.validateField?.(path);
2434
+ validateNow();
2097
2435
  }
2098
- }, [form, path]);
2436
+ }, [form, path, validateNow]);
2099
2437
  const push = react.useCallback(
2100
2438
  (item) => {
2101
2439
  form.arrayPush(path, item);
@@ -2160,13 +2498,17 @@ function useFieldArray(form, pathArg) {
2160
2498
  state.submitCount > 0,
2161
2499
  true
2162
2500
  )) {
2163
- form.validateField?.(path);
2501
+ validateNow();
2164
2502
  }
2165
- }, [form, path]);
2503
+ }, [form, path, validateNow]);
2166
2504
  const validate = react.useCallback(
2167
2505
  () => form.validateField?.(path) ?? { kind: "valid" },
2168
2506
  [form, path]
2169
2507
  );
2508
+ const validateAsync2 = react.useCallback(
2509
+ () => form.validateFieldAsync?.(path) ?? Promise.resolve({ kind: "valid" }),
2510
+ [form, path]
2511
+ );
2170
2512
  const replace = react.useCallback(
2171
2513
  (next) => {
2172
2514
  form.setValue?.(path, next);
@@ -2176,7 +2518,15 @@ function useFieldArray(form, pathArg) {
2176
2518
  );
2177
2519
  const update = react.useCallback(
2178
2520
  (index, item) => {
2179
- form.setValue?.(`${path}.${index}`, item);
2521
+ const current = getAtPath(form.store.getState().values, path);
2522
+ const length = Array.isArray(current) ? current.length : 0;
2523
+ if (!Number.isInteger(index) || index < 0 || index >= length) {
2524
+ console.warn(
2525
+ `[formstand] array op on "${path}" with an out-of-range or non-integer index (length ${String(length)}). Operation skipped.`
2526
+ );
2527
+ return;
2528
+ }
2529
+ form.setValue?.(`${path}.${String(index)}`, item);
2180
2530
  revalidate();
2181
2531
  },
2182
2532
  [form, path, revalidate]
@@ -2208,6 +2558,7 @@ function useFieldArray(form, pathArg) {
2208
2558
  setTouched,
2209
2559
  onBlur,
2210
2560
  validate,
2561
+ validateAsync: validateAsync2,
2211
2562
  push,
2212
2563
  remove,
2213
2564
  insert,
@@ -2231,6 +2582,7 @@ function useFieldArray(form, pathArg) {
2231
2582
  setTouched,
2232
2583
  onBlur,
2233
2584
  validate,
2585
+ validateAsync2,
2234
2586
  push,
2235
2587
  remove,
2236
2588
  insert,
@@ -2258,29 +2610,41 @@ var useFormError = (form) => react$1.useStore(
2258
2610
  function useFormValues(form) {
2259
2611
  return react$1.useStore(form.store, (state) => state.values);
2260
2612
  }
2261
-
2262
- // src/react/useFields.ts
2263
- function useFields(form, paths) {
2613
+ function useFields(form, paths, options) {
2614
+ const [arity] = react.useState(paths.length);
2615
+ if (paths.length !== arity) {
2616
+ throw new Error(
2617
+ `[formstand] useFields: the paths tuple changed length (${String(arity)} to ${String(paths.length)}); its length and order must be stable across renders, so pass a literal or a module-level constant.`
2618
+ );
2619
+ }
2264
2620
  const fields = paths.map(
2265
2621
  (path) => (
2266
2622
  // 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
2267
- useField(form, path)
2623
+ useField(
2624
+ form,
2625
+ path,
2626
+ options
2627
+ )
2268
2628
  )
2269
2629
  );
2270
- const merged = [...new Set(fields.flatMap((field) => field.error ?? []))];
2271
- return {
2272
- fields,
2273
- error: merged.length > 0 ? merged : void 0,
2274
- firstError: merged[0],
2275
- touched: fields.some((field) => field.touched),
2276
- dirty: fields.some((field) => field.dirty),
2277
- isValidating: fields.some((field) => field.isValidating),
2278
- onBlur: () => {
2279
- fields.forEach((field) => {
2280
- field.onBlur();
2281
- });
2282
- }
2283
- };
2630
+ return react.useMemo(() => {
2631
+ const merged = [
2632
+ ...new Set(fields.flatMap((field) => field.error ?? []))
2633
+ ];
2634
+ return {
2635
+ fields,
2636
+ error: merged.length > 0 ? merged : void 0,
2637
+ firstError: merged[0],
2638
+ touched: fields.some((field) => field.touched),
2639
+ dirty: fields.some((field) => field.dirty),
2640
+ isValidating: fields.some((field) => field.isValidating),
2641
+ onBlur: () => {
2642
+ fields.forEach((field) => {
2643
+ field.onBlur();
2644
+ });
2645
+ }
2646
+ };
2647
+ }, fields);
2284
2648
  }
2285
2649
  function useIsDirty(form, path) {
2286
2650
  return react$1.useStore(
@@ -2323,10 +2687,11 @@ var capitalize = (name) => name.length === 0 ? name : name.charAt(0).toUpperCase
2323
2687
  var createFormHooks = (form, name) => {
2324
2688
  const structural = form;
2325
2689
  const useBoundField = (path, options) => useField(structural, path, options);
2326
- const useBoundVariantField = (unionPath, field) => useVariantField(
2690
+ const useBoundVariantField = (unionPath, field, options) => useVariantField(
2327
2691
  structural,
2328
2692
  unionPath,
2329
- field
2693
+ field,
2694
+ options
2330
2695
  );
2331
2696
  const useBoundFieldArray = (path) => useFieldArray(structural, path);
2332
2697
  const useBoundVariantFieldArray = (unionPath, field) => useVariantFieldArray(
@@ -2342,12 +2707,16 @@ var createFormHooks = (form, name) => {
2342
2707
  const useBoundIsValidating = (path) => useIsValidating(structural, path);
2343
2708
  const useBoundIsSubmitting = () => useIsSubmitting(structural);
2344
2709
  const useBoundValues = () => useFormValues(structural);
2345
- const useBoundFields = (paths) => useFields(
2710
+ const useBoundFields = (paths, options) => useFields(
2346
2711
  structural,
2347
- paths
2712
+ paths,
2713
+ options
2348
2714
  );
2349
2715
  const useBoundSubmitCount = () => useSubmitCount(structural);
2350
2716
  const useBoundSteps = (steps) => useFormSteps(form, steps);
2717
+ const useBoundAction = (onValid, options) => useFormAction(form, onValid, options);
2718
+ const useBoundActionState = (action, initialState, options) => useFormActionState(form, action, initialState, options);
2719
+ const useBoundPersist = (options) => usePersistForm(form, options);
2351
2720
  const prefix = capitalize(name ?? "");
2352
2721
  return {
2353
2722
  [`use${prefix}Field`]: useBoundField,
@@ -2364,7 +2733,10 @@ var createFormHooks = (form, name) => {
2364
2733
  [`use${prefix}SubmitCount`]: useBoundSubmitCount,
2365
2734
  [`use${prefix}Values`]: useBoundValues,
2366
2735
  [`use${prefix}Fields`]: useBoundFields,
2367
- [`use${prefix}Steps`]: useBoundSteps
2736
+ [`use${prefix}Steps`]: useBoundSteps,
2737
+ [`use${prefix}Action`]: useBoundAction,
2738
+ [`use${prefix}ActionState`]: useBoundActionState,
2739
+ [`use${prefix}Persist`]: useBoundPersist
2368
2740
  };
2369
2741
  };
2370
2742
  var useFieldA11y = (error) => {
@@ -2388,7 +2760,7 @@ var FieldShell = ({
2388
2760
  wrapLabel,
2389
2761
  children
2390
2762
  }) => /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "zf-field", children: [
2391
- wrapLabel === true ? /* @__PURE__ */ jsxRuntime.jsxs("label", { htmlFor: id, className: "zf-label", children: [
2763
+ wrapLabel === true && label !== void 0 ? /* @__PURE__ */ jsxRuntime.jsxs("label", { htmlFor: id, className: "zf-label", children: [
2392
2764
  children,
2393
2765
  " ",
2394
2766
  label