formstand 0.25.0 → 0.26.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/dist/index.cjs CHANGED
@@ -298,6 +298,12 @@ var validateAsync = async (schema, values) => {
298
298
  return result.success ? { kind: "valid", data: result.data } : { kind: "invalid", errors: flattenIssues(result.error.issues) };
299
299
  };
300
300
  var isAsyncRequiredError = (e) => e instanceof zod.z.core.$ZodAsyncError || e instanceof Error && /Encountered Promise during synchronous parse/i.test(e.message);
301
+ var reportBackgroundFailure = (error) => {
302
+ console.error(
303
+ "[formstand] a background validation pass threw (an async refinement rejected). Its in-flight flags were released. The error:",
304
+ error
305
+ );
306
+ };
301
307
  var isPathOrChild = (key, path) => key === path || key.startsWith(`${path}.`);
302
308
  var prefixErrorKeys = (errors, path) => path === "" ? errors : Object.fromEntries(
303
309
  Object.entries(errors).map(([k, v]) => [
@@ -349,6 +355,42 @@ var schemaHasPath = (schema, path) => {
349
355
  };
350
356
  return walk(schema, parsePath(path), SCHEMA_WALK_BUDGET);
351
357
  };
358
+ var schemaAllowsKindChangeAt = (schema, path) => {
359
+ 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;
360
+ const strip = (s) => {
361
+ if (s instanceof zod.z.ZodOptional || s instanceof zod.z.ZodNullable) {
362
+ return strip(s.unwrap());
363
+ }
364
+ if (s instanceof zod.z.ZodDefault || s instanceof zod.z.ZodPrefault || s instanceof zod.z.ZodReadonly || s instanceof zod.z.ZodNonOptional) {
365
+ return strip(s.def.innerType);
366
+ }
367
+ return s;
368
+ };
369
+ const walk = (s, segments, budget) => {
370
+ if (budget <= 0) return true;
371
+ const node = strip(s);
372
+ if (flexible(node)) return true;
373
+ const [head, ...rest] = segments;
374
+ if (head === void 0) return false;
375
+ if (node instanceof zod.z.ZodObject) {
376
+ const shape = node.shape;
377
+ const child = Object.hasOwn(shape, String(head)) ? shape[String(head)] : void 0;
378
+ return child === void 0 ? true : walk(child, rest, budget - 1);
379
+ }
380
+ if (node instanceof zod.z.ZodArray) {
381
+ return typeof head === "number" ? walk(node.element, rest, budget - 1) : true;
382
+ }
383
+ if (node instanceof zod.z.ZodTuple && typeof head === "number") {
384
+ const item = node.def.items[head];
385
+ return item === void 0 ? true : walk(item, rest, budget - 1);
386
+ }
387
+ if (node instanceof zod.z.ZodRecord) {
388
+ return walk(node.valueType, rest, budget - 1);
389
+ }
390
+ return true;
391
+ };
392
+ return walk(schema, parsePath(path), SCHEMA_WALK_BUDGET);
393
+ };
352
394
  var hasChecks = (s) => {
353
395
  const checks = s.def.checks;
354
396
  return checks !== void 0 && checks.length > 0;
@@ -433,7 +475,7 @@ var fieldSchemaAtPath = (schema, path) => parsePath(path).reduce((current, segme
433
475
  }, schema);
434
476
 
435
477
  // 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;
478
+ 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
479
  var emptyErrors = {};
438
480
  var emptyBools = {};
439
481
  var identityMapper = (n) => n;
@@ -491,7 +533,26 @@ var reuseErrorRefs = (next, prev) => {
491
533
  const sameShape = entries.length === Object.keys(prev).length && entries.every(([k, v]) => entryAt(prev, k) === v);
492
534
  return sameShape ? prev : Object.fromEntries(entries);
493
535
  };
494
- var scopeSettledResult = (result, path, viaSubschema) => result.kind !== "invalid" ? emptyErrors : viaSubschema ? prefixErrorKeys(result.errors, path) : path === "" ? result.errors : pickScope(result.errors, [path]);
536
+ var noScopeErrors = { scoped: emptyErrors, full: null };
537
+ var scopeParse = (result, path, viaSubschema) => {
538
+ const errors = result.kind === "invalid" ? result.errors : emptyErrors;
539
+ return viaSubschema ? { scoped: prefixErrorKeys(errors, path), full: null } : {
540
+ scoped: path === "" ? errors : pickScope(errors, [path]),
541
+ full: errors
542
+ };
543
+ };
544
+ var pruneUnreported = (prev, full) => {
545
+ const entries = Object.entries(prev);
546
+ const kept = entries.filter(([k]) => Object.hasOwn(full, k));
547
+ return kept.length === entries.length ? prev : Object.fromEntries(kept);
548
+ };
549
+ var dropEmptyLists = (map) => {
550
+ const entries = Object.entries(map);
551
+ const kept = entries.filter(
552
+ ([, list]) => list !== void 0 && list.length > 0
553
+ );
554
+ return kept.length === entries.length ? map : Object.fromEntries(kept);
555
+ };
495
556
  var mergeErrorChannels = (schema, server) => {
496
557
  const extra = Object.entries(server).filter(
497
558
  ([k]) => !Object.hasOwn(schema, k)
@@ -601,17 +662,17 @@ var createForm = (schema, options) => {
601
662
  const merged = scopes.reduce(
602
663
  (acc, { path, scope }) => scope.kind === "skip" ? acc : {
603
664
  ...acc,
604
- ...scopeSettledResult(
665
+ ...scopeParse(
605
666
  validateSyncLatched(path, scope.schema, scope.value),
606
667
  path,
607
668
  true
608
- )
669
+ ).scoped
609
670
  },
610
671
  {}
611
672
  );
612
673
  store.setState((state) => ({
613
674
  ...state,
614
- ...commitScopedErrors(state, merged, paths)
675
+ ...commitScopedErrors(state, merged, paths, null)
615
676
  }));
616
677
  return settledFields(merged);
617
678
  }
@@ -620,7 +681,7 @@ var createForm = (schema, options) => {
620
681
  const requestedErrors = pickScope(fullErrors, paths);
621
682
  store.setState((state) => ({
622
683
  ...state,
623
- ...commitScopedErrors(state, requestedErrors, paths)
684
+ ...commitScopedErrors(state, requestedErrors, paths, fullErrors)
624
685
  }));
625
686
  return settledFields(requestedErrors);
626
687
  } catch (e) {
@@ -665,7 +726,7 @@ var createForm = (schema, options) => {
665
726
  const slot = slotAtPath(values, path);
666
727
  return slot.exists ? { kind: "parse", schema: sub, value: slot.value, viaSubschema: true } : { kind: "skip" };
667
728
  };
668
- const parseScopeAsync = async (path, scope) => scope.kind === "skip" ? emptyErrors : scopeSettledResult(
729
+ const parseScopeAsync = async (path, scope) => scope.kind === "skip" ? noScopeErrors : scopeParse(
669
730
  await validateAsync(scope.schema, scope.value),
670
731
  path,
671
732
  scope.viaSubschema
@@ -685,14 +746,18 @@ var createForm = (schema, options) => {
685
746
  };
686
747
  const settledFields = (scoped) => Object.keys(scoped).length === 0 ? { kind: "valid" } : { kind: "invalid", errors: scoped };
687
748
  const settledFormResult = (result) => settledFields(result.kind === "invalid" ? result.errors : emptyErrors);
688
- const commitScopedErrors = (state, scoped, paths) => errorChannels(state, {
749
+ const commitScopedErrors = (state, scoped, paths, full) => errorChannels(state, {
689
750
  schemaErrors: reuseErrorRefs(
690
- mergeScopedErrors(state.schemaErrors, scoped, paths),
751
+ mergeScopedErrors(
752
+ full === null ? state.schemaErrors : pruneUnreported(state.schemaErrors, full),
753
+ scoped,
754
+ paths
755
+ ),
691
756
  state.schemaErrors
692
757
  ),
693
758
  serverErrors: omitScope(state.serverErrors, paths)
694
759
  });
695
- const commitFieldErrors = (state, scoped, path) => path === "" ? commitFullPass(state, scoped) : commitScopedErrors(state, scoped, [path]);
760
+ const commitFieldErrors = (state, parse, path) => path === "" ? commitFullPass(state, parse.scoped) : commitScopedErrors(state, parse.scoped, [path], parse.full);
696
761
  const validateField = (path) => {
697
762
  warnMissingSchemaPath("validateField", path);
698
763
  const scope = fieldScopeFor(path, store.getState().values);
@@ -700,7 +765,7 @@ var createForm = (schema, options) => {
700
765
  return { kind: "pending", promise: validateFieldAsync(path) };
701
766
  }
702
767
  try {
703
- const scoped = scope.kind === "skip" ? emptyErrors : scopeSettledResult(
768
+ const parse = scope.kind === "skip" ? noScopeErrors : scopeParse(
704
769
  validateSyncLatched(
705
770
  scope.viaSubschema ? path : "",
706
771
  scope.schema,
@@ -711,9 +776,9 @@ var createForm = (schema, options) => {
711
776
  );
712
777
  store.setState((state) => ({
713
778
  ...state,
714
- ...commitFieldErrors(state, scoped, path)
779
+ ...commitFieldErrors(state, parse, path)
715
780
  }));
716
- return fieldResult(scoped);
781
+ return fieldResult(parse.scoped);
717
782
  } catch (e) {
718
783
  if (isAsyncRequiredError(e)) {
719
784
  return { kind: "pending", promise: validateFieldAsync(path) };
@@ -723,11 +788,15 @@ var createForm = (schema, options) => {
723
788
  };
724
789
  const snapshotField = (state, path) => {
725
790
  const value = getAtPath(state.values, path);
791
+ const initialValue = getAtPath(state.initialValues, path);
792
+ const error = entryAt(state.errors, path);
726
793
  return {
727
794
  value,
728
- error: entryAt(state.errors, path),
795
+ initialValue,
796
+ error,
797
+ firstError: error?.[0],
729
798
  touched: flagAt(state.touched, path),
730
- dirty: isFieldDirty(value, getAtPath(state.initialValues, path)),
799
+ dirty: isFieldDirty(value, initialValue),
731
800
  isValidating: flagAt(state.isValidating, path)
732
801
  };
733
802
  };
@@ -741,7 +810,14 @@ var createForm = (schema, options) => {
741
810
  const pass = claimPass(FORM_SEQ_KEY);
742
811
  const valuesAtStart = store.getState().values;
743
812
  store.setState((state) => ({ ...state, isValidatingForm: true }));
744
- const result = await validateAsync(schema, valuesAtStart);
813
+ const result = await validateAsync(schema, valuesAtStart).catch(
814
+ (error) => {
815
+ if (sequences.get(FORM_SEQ_KEY) === pass) {
816
+ store.setState((state) => ({ ...state, isValidatingForm: false }));
817
+ }
818
+ throw error;
819
+ }
820
+ );
745
821
  const live = store.getState();
746
822
  const stillOwns = sequences.get(FORM_SEQ_KEY) === pass;
747
823
  if (!stillOwns || live.values !== valuesAtStart) {
@@ -781,12 +857,15 @@ var createForm = (schema, options) => {
781
857
  ...Object.fromEntries(paths.map((p) => [p, true]))
782
858
  }
783
859
  }));
784
- const parsed = allViaSubschema ? {
860
+ const parseBatch = async () => allViaSubschema ? {
785
861
  kind: "slots",
786
862
  byPath: new Map(
787
863
  await Promise.all(
788
864
  scopes.map(
789
- async ({ path, scope }) => [path, await parseScopeAsync(path, scope)]
865
+ async ({ path, scope }) => [
866
+ path,
867
+ (await parseScopeAsync(path, scope)).scoped
868
+ ]
790
869
  )
791
870
  )
792
871
  )
@@ -796,6 +875,18 @@ var createForm = (schema, options) => {
796
875
  (result) => result.kind === "invalid" ? result.errors : emptyErrors
797
876
  )
798
877
  };
878
+ const parsed = await parseBatch().catch((error) => {
879
+ const stillOwned = new Set(
880
+ paths.filter((p) => sequences.get(p) === passes.get(p))
881
+ );
882
+ if (stillOwned.size > 0) {
883
+ store.setState((state) => ({
884
+ ...state,
885
+ isValidating: omitKeys(state.isValidating, stillOwned)
886
+ }));
887
+ }
888
+ throw error;
889
+ });
799
890
  const errorsFor = (path) => {
800
891
  switch (parsed.kind) {
801
892
  case "slots":
@@ -824,7 +915,8 @@ var createForm = (schema, options) => {
824
915
  Object.fromEntries(
825
916
  fresh.flatMap((path) => Object.entries(errorsFor(path)))
826
917
  ),
827
- fresh
918
+ fresh,
919
+ parsed.kind === "full" ? parsed.errors : null
828
920
  ),
829
921
  isValidating: omitKeys(state.isValidating, ownedKeys)
830
922
  }));
@@ -845,24 +937,32 @@ var createForm = (schema, options) => {
845
937
  ...state,
846
938
  isValidating: { ...state.isValidating, [path]: true }
847
939
  }));
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) {
940
+ const releaseOwnFlag = () => {
941
+ if (sequences.get(path) === pass) {
853
942
  store.setState((state) => ({
854
943
  ...state,
855
944
  isValidating: omitKey(state.isValidating, path)
856
945
  }));
857
946
  }
858
- return fieldResult(scoped);
947
+ };
948
+ const parse = await parseScopeAsync(path, scope).catch(
949
+ (error) => {
950
+ releaseOwnFlag();
951
+ throw error;
952
+ }
953
+ );
954
+ const live = store.getState();
955
+ const stillOwns = sequences.get(path) === pass;
956
+ if (!stillOwns || scopeStale(scope, path, valuesAtStart, live.values)) {
957
+ releaseOwnFlag();
958
+ return fieldResult(parse.scoped);
859
959
  }
860
960
  store.setState((state) => ({
861
961
  ...state,
862
- ...commitFieldErrors(state, scoped, path),
962
+ ...commitFieldErrors(state, parse, path),
863
963
  isValidating: omitKey(state.isValidating, path)
864
964
  }));
865
- return fieldResult(scoped);
965
+ return fieldResult(parse.scoped);
866
966
  };
867
967
  const applyArrayOp = (path, nextArray, mapper, indexInBounds) => {
868
968
  const current = getAtPath(store.getState().values, path);
@@ -921,7 +1021,9 @@ var createForm = (schema, options) => {
921
1021
  count: 0,
922
1022
  baseline: false
923
1023
  };
924
- const submit = async (onValid, onInvalid, submitOptions) => {
1024
+ const submit = async (onValid, onInvalidOrOptions, positionalOptions) => {
1025
+ const submitOptions = typeof onInvalidOrOptions === "object" ? onInvalidOrOptions : positionalOptions;
1026
+ const onInvalid = typeof onInvalidOrOptions === "function" ? onInvalidOrOptions : submitOptions?.onInvalid;
925
1027
  if (inFlight.count > 0 && submitOptions?.force !== true) {
926
1028
  return { kind: "skipped" };
927
1029
  }
@@ -962,6 +1064,7 @@ var createForm = (schema, options) => {
962
1064
  try {
963
1065
  onInvalid?.(merged);
964
1066
  } catch (error) {
1067
+ submitOptions?.onError?.(error);
965
1068
  return { kind: "error", error };
966
1069
  }
967
1070
  return { kind: "invalid", errors: merged };
@@ -988,7 +1091,10 @@ var createForm = (schema, options) => {
988
1091
  }
989
1092
  };
990
1093
  if (options.validateOnMount === true) {
991
- validate();
1094
+ const result = validate();
1095
+ if (result.kind === "pending") {
1096
+ void result.promise.catch(reportBackgroundFailure);
1097
+ }
992
1098
  }
993
1099
  return Object.freeze({
994
1100
  schema,
@@ -1081,7 +1187,7 @@ var createForm = (schema, options) => {
1081
1187
  },
1082
1188
  setErrors: (errors) => store.setState((state) => ({
1083
1189
  ...state,
1084
- ...errorChannels(state, { serverErrors: errors })
1190
+ ...errorChannels(state, { serverErrors: dropEmptyLists(errors) })
1085
1191
  })),
1086
1192
  addErrors: (errors) => store.setState((state) => {
1087
1193
  const entries = Object.entries(errors);
@@ -1124,11 +1230,11 @@ var createForm = (schema, options) => {
1124
1230
  if (next.values !== state.values) {
1125
1231
  clearArrayOpsUnder(store, "");
1126
1232
  }
1127
- const serverErrors = patch.serverErrors !== void 0 ? patch.serverErrors : next.values !== state.values ? releaseChangedSlices(state.serverErrors, state.values, next.values) : state.serverErrors;
1233
+ const serverErrors = patch.serverErrors !== void 0 ? dropEmptyLists(patch.serverErrors) : next.values !== state.values ? releaseChangedSlices(state.serverErrors, state.values, next.values) : state.serverErrors;
1128
1234
  store.setState(() => ({
1129
1235
  ...next,
1130
1236
  ...errorChannels(state, {
1131
- schemaErrors: next.schemaErrors,
1237
+ schemaErrors: patch.schemaErrors === void 0 ? state.schemaErrors : dropEmptyLists(patch.schemaErrors),
1132
1238
  serverErrors
1133
1239
  })
1134
1240
  }));
@@ -1237,9 +1343,14 @@ var createForm = (schema, options) => {
1237
1343
  swapIndices(a, b),
1238
1344
  (len) => Number.isInteger(a) && Number.isInteger(b) && a >= 0 && a < len && b >= 0 && b < len
1239
1345
  ),
1240
- handleSubmit: (onValid, onInvalid, opts) => async (event) => {
1346
+ handleSubmit: (onValid, onInvalidOrOptions, positionalOptions) => async (event) => {
1241
1347
  event?.preventDefault();
1242
- const result = await submit(onValid, onInvalid, opts);
1348
+ const opts = typeof onInvalidOrOptions === "object" ? onInvalidOrOptions : positionalOptions;
1349
+ const result = await submit(
1350
+ onValid,
1351
+ onInvalidOrOptions,
1352
+ positionalOptions
1353
+ );
1243
1354
  if (result.kind === "error" && opts?.onError === void 0 && isDevBuild()) {
1244
1355
  console.warn(
1245
1356
  "[formstand] submit handler threw and nothing observed it \u2014 the form shows no feedback. Pass { onError } to handleSubmit (or setError inside your handler):",
@@ -1265,8 +1376,8 @@ var createForm = (schema, options) => {
1265
1376
  restore: (snap) => {
1266
1377
  clearArrayOpsUnder(store, "");
1267
1378
  store.setState(() => {
1268
- const schemaErrors = snap.schemaErrors ?? emptyErrors;
1269
- const serverErrors = snap.serverErrors ?? emptyErrors;
1379
+ const schemaErrors = dropEmptyLists(snap.schemaErrors ?? emptyErrors);
1380
+ const serverErrors = dropEmptyLists(snap.serverErrors ?? emptyErrors);
1270
1381
  return {
1271
1382
  ...snap,
1272
1383
  schemaErrors,
@@ -1313,11 +1424,13 @@ var createDebouncer = (ms) => {
1313
1424
  const timer = {
1314
1425
  current: null
1315
1426
  };
1427
+ const scheduled = { current: null };
1316
1428
  const cancel = () => {
1317
1429
  if (timer.current !== null) {
1318
1430
  clearTimeout(timer.current);
1319
1431
  timer.current = null;
1320
1432
  }
1433
+ scheduled.current = null;
1321
1434
  };
1322
1435
  const schedule = (run) => {
1323
1436
  cancel();
@@ -1325,37 +1438,50 @@ var createDebouncer = (ms) => {
1325
1438
  run();
1326
1439
  return;
1327
1440
  }
1441
+ scheduled.current = run;
1328
1442
  timer.current = setTimeout(() => {
1329
1443
  timer.current = null;
1444
+ scheduled.current = null;
1330
1445
  run();
1331
1446
  }, ms);
1332
1447
  };
1448
+ const flush = () => {
1449
+ const run = scheduled.current;
1450
+ if (run === null) return;
1451
+ cancel();
1452
+ run();
1453
+ };
1333
1454
  return Object.freeze({
1334
1455
  schedule,
1335
1456
  cancel,
1457
+ flush,
1336
1458
  pending: () => timer.current !== null
1337
1459
  });
1338
1460
  };
1339
1461
 
1340
1462
  // src/core/persist.ts
1341
1463
  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;
1464
+ var joinPath = (base, key) => base === "" ? String(key) : `${base}.${String(key)}`;
1465
+ var conflictPath = (draft, reference, path, flexibleAt) => {
1466
+ if (draft === void 0 || draft === null) return null;
1467
+ if (reference === void 0 || reference === null) return null;
1345
1468
  const draftKind = kindOf(draft);
1346
1469
  const referenceKind = kindOf(reference);
1347
- if (draftKind !== referenceKind) return true;
1470
+ if (draftKind !== referenceKind) return flexibleAt(path) ? null : path;
1348
1471
  if (draftKind === "array") {
1349
1472
  const [d] = draft;
1350
1473
  const [r] = reference;
1351
- return conflictsWith(d, r);
1474
+ return conflictPath(d, r, joinPath(path, 0), flexibleAt);
1352
1475
  }
1353
1476
  if (draftKind === "object") {
1354
1477
  const d = draft;
1355
1478
  const r = reference;
1356
- return Object.keys(d).some((key) => conflictsWith(d[key], r[key]));
1479
+ return Object.keys(d).reduce(
1480
+ (found, key) => found ?? conflictPath(d[key], r[key], joinPath(path, key), flexibleAt),
1481
+ null
1482
+ );
1357
1483
  }
1358
- return false;
1484
+ return null;
1359
1485
  };
1360
1486
  var defaultStorage = () => {
1361
1487
  try {
@@ -1368,7 +1494,35 @@ var persistForm = (form, options) => {
1368
1494
  const storage = options.storage ?? defaultStorage();
1369
1495
  const debounceMs = options.debounceMs ?? 300;
1370
1496
  const apply = options.apply ?? "adopt";
1371
- const applyDraft = (values) => apply === "edit" || apply === "restore" ? form.setValues(values) : form.adoptValues(values);
1497
+ const schema = form.schema;
1498
+ const fillRequiredGaps = (draft, initial, path) => {
1499
+ if (!isPlainObject(draft) || !isPlainObject(initial)) return draft;
1500
+ return Object.keys(initial).reduce(
1501
+ (acc, key) => {
1502
+ const childPath = joinPath(path, key);
1503
+ if (Object.hasOwn(draft, key)) {
1504
+ const next = fillRequiredGaps(draft[key], initial[key], childPath);
1505
+ return Object.is(next, draft[key]) ? acc : { ...acc, [key]: next };
1506
+ }
1507
+ if (initial[key] === void 0) return acc;
1508
+ const sub = fieldSchemaForIntrospection(schema, childPath);
1509
+ return sub !== null && isClearableSchema(sub) ? acc : { ...acc, [key]: initial[key] };
1510
+ },
1511
+ draft
1512
+ );
1513
+ };
1514
+ const applyDraft = (values) => {
1515
+ const filled = fillRequiredGaps(
1516
+ values,
1517
+ form.getState().initialValues,
1518
+ ""
1519
+ );
1520
+ if (apply === "edit" || apply === "restore") {
1521
+ form.setValues(filled);
1522
+ } else {
1523
+ form.adoptValues(filled);
1524
+ }
1525
+ };
1372
1526
  const referenceShape = () => {
1373
1527
  try {
1374
1528
  return JSON.parse(JSON.stringify(form.getState().initialValues));
@@ -1376,30 +1530,67 @@ var persistForm = (form, options) => {
1376
1530
  return void 0;
1377
1531
  }
1378
1532
  };
1379
- const load = () => {
1533
+ const flexibleAt = (path) => schemaAllowsKindChangeAt(schema, path);
1534
+ const inspect = () => {
1535
+ const raw = (() => {
1536
+ try {
1537
+ return storage === null ? null : storage.getItem(options.key);
1538
+ } catch {
1539
+ return null;
1540
+ }
1541
+ })();
1542
+ if (raw === null) return { kind: "absent" };
1380
1543
  try {
1381
- const raw = storage === null ? null : storage.getItem(options.key);
1382
- if (raw === null) return false;
1383
1544
  const parsed = JSON.parse(raw);
1384
1545
  const versioned = options.version !== void 0 && typeof parsed === "object" && parsed !== null && "__v" in parsed;
1385
- if (options.version !== void 0 && !versioned) return false;
1546
+ if (options.version !== void 0 && !versioned) {
1547
+ return {
1548
+ kind: "ignored",
1549
+ reason: `it carries no version and this configuration expects version ${String(options.version)}`
1550
+ };
1551
+ }
1386
1552
  if (options.version === void 0 && typeof parsed === "object" && parsed !== null && Object.hasOwn(parsed, "__v") && Object.hasOwn(parsed, "values")) {
1387
- return false;
1553
+ return {
1554
+ kind: "ignored",
1555
+ reason: "it was written with a version and this configuration has none"
1556
+ };
1388
1557
  }
1389
1558
  const wrapper = parsed;
1390
- if (versioned && wrapper.__v !== options.version) return false;
1391
- if (versioned && wrapper.values === void 0) return false;
1559
+ if (versioned && wrapper.__v !== options.version) {
1560
+ return {
1561
+ kind: "ignored",
1562
+ reason: `its version (${String(wrapper.__v)}) is not the configured version (${String(options.version)})`
1563
+ };
1564
+ }
1565
+ if (versioned && wrapper.values === void 0) {
1566
+ return { kind: "ignored", reason: "its version wrapper holds no values" };
1567
+ }
1392
1568
  const values = versioned ? wrapper.values : parsed;
1393
- if (values === null || values === void 0) return false;
1569
+ if (values === null || values === void 0) {
1570
+ return { kind: "ignored", reason: "it holds no values" };
1571
+ }
1394
1572
  const reference = referenceShape();
1395
- if (reference !== void 0 && conflictsWith(values, reference)) {
1396
- return false;
1573
+ const conflict = reference === void 0 ? null : conflictPath(values, reference, "", flexibleAt);
1574
+ if (conflict !== null) {
1575
+ return {
1576
+ kind: "ignored",
1577
+ reason: `its shape conflicts with the form's initial values at "${conflict}" (set the version option when the schema changes)`
1578
+ };
1397
1579
  }
1398
1580
  applyDraft(values);
1399
- return true;
1581
+ return { kind: "applied" };
1400
1582
  } catch {
1401
- return false;
1583
+ return { kind: "ignored", reason: "it is not valid JSON" };
1584
+ }
1585
+ };
1586
+ const load = () => {
1587
+ const outcome = inspect();
1588
+ if (outcome.kind === "ignored" && isDevBuild()) {
1589
+ console.warn(
1590
+ `[formstand] persistForm: ignored the draft under "${options.key}": ${outcome.reason}. The next edit overwrites it.`
1591
+ );
1402
1592
  }
1593
+ return outcome.kind === "applied";
1403
1594
  };
1404
1595
  const writeDraft = (values) => {
1405
1596
  try {
@@ -1413,24 +1604,28 @@ var persistForm = (form, options) => {
1413
1604
  }
1414
1605
  };
1415
1606
  const writes = createDebouncer(debounceMs);
1416
- const cancelPending = () => writes.cancel();
1417
1607
  if (apply !== "manual") {
1418
1608
  load();
1419
1609
  }
1610
+ const cleared = { current: false };
1420
1611
  const unsubscribe = form.watchValues((values) => {
1612
+ if (cleared.current && values === form.getState().initialValues) return;
1613
+ cleared.current = false;
1421
1614
  writes.schedule(() => writeDraft(values));
1422
1615
  });
1423
1616
  return Object.freeze({
1424
1617
  load,
1618
+ flush: () => writes.flush(),
1425
1619
  clear: () => {
1426
- cancelPending();
1620
+ writes.cancel();
1621
+ cleared.current = true;
1427
1622
  try {
1428
1623
  storage?.removeItem(options.key);
1429
1624
  } catch {
1430
1625
  }
1431
1626
  },
1432
1627
  dispose: () => {
1433
- cancelPending();
1628
+ writes.flush();
1434
1629
  unsubscribe();
1435
1630
  }
1436
1631
  });
@@ -1442,6 +1637,7 @@ var usePersistForm = (form, options) => {
1442
1637
  latestOptions.current = options;
1443
1638
  });
1444
1639
  const key = options.key;
1640
+ const storage = options.storage;
1445
1641
  react.useEffect(() => {
1446
1642
  const handle = persistForm(form, latestOptions.current);
1447
1643
  inner.current = handle;
@@ -1449,10 +1645,11 @@ var usePersistForm = (form, options) => {
1449
1645
  inner.current = null;
1450
1646
  handle.dispose();
1451
1647
  };
1452
- }, [form, key]);
1648
+ }, [form, key, storage]);
1453
1649
  return react.useMemo(
1454
1650
  () => ({
1455
1651
  load: () => inner.current?.load() ?? false,
1652
+ flush: () => inner.current?.flush(),
1456
1653
  clear: () => inner.current?.clear(),
1457
1654
  // dispose stops THIS component's watching early; the effect cleanup
1458
1655
  // still runs its own dispose harmlessly (persistForm's dispose is
@@ -1486,9 +1683,12 @@ var useForm = (schema, options) => {
1486
1683
  var hasFieldError = (error) => error !== void 0 && error.length > 0;
1487
1684
  var ariaInvalid = (field) => hasFieldError(field.error) ? true : void 0;
1488
1685
  var numberToInputText = (value) => value === void 0 || value === null || Number.isNaN(value) ? "" : String(value);
1686
+ var DECIMAL_TEXT = /^[+-]?(\d+\.?\d*|\.\d+)([eE][+-]?\d+)?$/;
1489
1687
  var parseNumberText = (text) => {
1490
- if (text.trim() === "") return { kind: "empty" };
1491
- const parsed = Number(text);
1688
+ const trimmed = text.trim();
1689
+ if (trimmed === "") return { kind: "empty" };
1690
+ if (!DECIMAL_TEXT.test(trimmed)) return { kind: "invalid" };
1691
+ const parsed = Number(trimmed);
1492
1692
  return Number.isFinite(parsed) ? { kind: "number", value: parsed } : { kind: "invalid" };
1493
1693
  };
1494
1694
  var textInputProps = (field) => ({
@@ -1516,11 +1716,11 @@ var numberInputProps = (field) => ({
1516
1716
  onBlur: field.onBlur
1517
1717
  });
1518
1718
  var dateToInputText = (value) => {
1519
- if (value === void 0 || value === null || Number.isNaN(value.getTime())) {
1520
- return "";
1521
- }
1719
+ if (value === void 0 || value === null) return "";
1720
+ const date = value instanceof Date ? value : new Date(value);
1721
+ if (Number.isNaN(date.getTime())) return "";
1522
1722
  const pad = (part) => String(part).padStart(2, "0");
1523
- return `${String(value.getFullYear()).padStart(4, "0")}-${pad(value.getMonth() + 1)}-${pad(value.getDate())}`;
1723
+ return `${String(date.getFullYear()).padStart(4, "0")}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`;
1524
1724
  };
1525
1725
  var parseDateText = (text) => {
1526
1726
  if (text.trim() === "") return { kind: "empty" };
@@ -1600,7 +1800,8 @@ var useMaskedInput = (field, options) => {
1600
1800
  field.setValue(parsed.value);
1601
1801
  return;
1602
1802
  case "empty": {
1603
- const empty = field.clearable || typeof field.initialValue !== "string" ? field.emptyValue : "";
1803
+ const isStringField = typeof field.initialValue === "string" || typeof field.value === "string";
1804
+ const empty = field.clearable || !isStringField ? field.emptyValue : "";
1604
1805
  setEdit({ raw: text, pushed: empty });
1605
1806
  field.setValue(empty);
1606
1807
  return;
@@ -1667,7 +1868,8 @@ var useFormSteps = (form, steps) => {
1667
1868
  const goTo = react.useCallback(
1668
1869
  async (target) => {
1669
1870
  const count = latest.current.length;
1670
- const clamped = Math.max(0, Math.min(target, count - 1));
1871
+ if (!Number.isFinite(target)) return currentStep.current;
1872
+ const clamped = Math.max(0, Math.min(Math.trunc(target), count - 1));
1671
1873
  if (clamped <= step) {
1672
1874
  setStep(clamped);
1673
1875
  return clamped;
@@ -1740,8 +1942,7 @@ var useFormAction = (form, onValid, options) => {
1740
1942
  const { onValid: handler, options: opts } = latest.current;
1741
1943
  const result = await form.submit(
1742
1944
  (data) => handler(data, formData),
1743
- opts?.onInvalid,
1744
- opts?.onError === void 0 ? void 0 : { onError: opts.onError }
1945
+ opts ?? {}
1745
1946
  );
1746
1947
  warnUnobserved(result, opts, "useFormAction");
1747
1948
  },
@@ -1757,8 +1958,7 @@ var useFormActionState = (form, action, initialState, options) => {
1757
1958
  async (data) => {
1758
1959
  nextRef.current = await action(prev, data, formData);
1759
1960
  },
1760
- options?.onInvalid,
1761
- options?.onError === void 0 ? void 0 : { onError: options.onError }
1961
+ options ?? {}
1762
1962
  );
1763
1963
  warnUnobserved(result, options, "useFormActionState");
1764
1964
  return nextRef.current;
@@ -1824,6 +2024,12 @@ function useField(form, pathArg, options) {
1824
2024
  debouncerRef.current = null;
1825
2025
  };
1826
2026
  }, [form, path, debounceMs]);
2027
+ const validateNow = react.useCallback(() => {
2028
+ const result = form.validateField(path);
2029
+ if (result.kind === "pending") {
2030
+ void result.promise.catch(reportBackgroundFailure);
2031
+ }
2032
+ }, [form, path]);
1827
2033
  const triggerValidate = react.useCallback(() => {
1828
2034
  if (debounceMs !== void 0 && debounceMs > 0) {
1829
2035
  const debouncer = debouncerRef.current ?? createDebouncer(debounceMs);
@@ -1835,12 +2041,12 @@ function useField(form, pathArg, options) {
1835
2041
  const rebased = state.values !== scheduledValues && state.values === state.initialValues;
1836
2042
  if (rebased) return;
1837
2043
  if (!Object.is(getAtPath(state.values, path), scheduled)) return;
1838
- void form.validateFieldAsync(path);
2044
+ void form.validateFieldAsync(path).catch(reportBackgroundFailure);
1839
2045
  });
1840
2046
  return;
1841
2047
  }
1842
- form.validateField(path);
1843
- }, [form, path, debounceMs]);
2048
+ validateNow();
2049
+ }, [form, path, debounceMs, validateNow]);
1844
2050
  const setValue = react.useCallback(
1845
2051
  (value) => {
1846
2052
  const before = form.store.getState();
@@ -1889,9 +2095,10 @@ function useField(form, pathArg, options) {
1889
2095
  state.submitCount > 0,
1890
2096
  true
1891
2097
  )) {
1892
- triggerValidate();
2098
+ debouncerRef.current?.cancel();
2099
+ validateNow();
1893
2100
  }
1894
- }, [form, path, triggerValidate]);
2101
+ }, [form, path, validateNow]);
1895
2102
  return react.useMemo(
1896
2103
  () => ({
1897
2104
  path: slice.path,
@@ -2258,29 +2465,37 @@ var useFormError = (form) => react$1.useStore(
2258
2465
  function useFormValues(form) {
2259
2466
  return react$1.useStore(form.store, (state) => state.values);
2260
2467
  }
2261
-
2262
- // src/react/useFields.ts
2263
2468
  function useFields(form, paths) {
2469
+ const [arity] = react.useState(paths.length);
2470
+ if (paths.length !== arity) {
2471
+ throw new Error(
2472
+ `[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.`
2473
+ );
2474
+ }
2264
2475
  const fields = paths.map(
2265
2476
  (path) => (
2266
2477
  // 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
2478
  useField(form, path)
2268
2479
  )
2269
2480
  );
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
- };
2481
+ return react.useMemo(() => {
2482
+ const merged = [
2483
+ ...new Set(fields.flatMap((field) => field.error ?? []))
2484
+ ];
2485
+ return {
2486
+ fields,
2487
+ error: merged.length > 0 ? merged : void 0,
2488
+ firstError: merged[0],
2489
+ touched: fields.some((field) => field.touched),
2490
+ dirty: fields.some((field) => field.dirty),
2491
+ isValidating: fields.some((field) => field.isValidating),
2492
+ onBlur: () => {
2493
+ fields.forEach((field) => {
2494
+ field.onBlur();
2495
+ });
2496
+ }
2497
+ };
2498
+ }, fields);
2284
2499
  }
2285
2500
  function useIsDirty(form, path) {
2286
2501
  return react$1.useStore(
@@ -2348,6 +2563,9 @@ var createFormHooks = (form, name) => {
2348
2563
  );
2349
2564
  const useBoundSubmitCount = () => useSubmitCount(structural);
2350
2565
  const useBoundSteps = (steps) => useFormSteps(form, steps);
2566
+ const useBoundAction = (onValid, options) => useFormAction(form, onValid, options);
2567
+ const useBoundActionState = (action, initialState, options) => useFormActionState(form, action, initialState, options);
2568
+ const useBoundPersist = (options) => usePersistForm(form, options);
2351
2569
  const prefix = capitalize(name ?? "");
2352
2570
  return {
2353
2571
  [`use${prefix}Field`]: useBoundField,
@@ -2364,7 +2582,10 @@ var createFormHooks = (form, name) => {
2364
2582
  [`use${prefix}SubmitCount`]: useBoundSubmitCount,
2365
2583
  [`use${prefix}Values`]: useBoundValues,
2366
2584
  [`use${prefix}Fields`]: useBoundFields,
2367
- [`use${prefix}Steps`]: useBoundSteps
2585
+ [`use${prefix}Steps`]: useBoundSteps,
2586
+ [`use${prefix}Action`]: useBoundAction,
2587
+ [`use${prefix}ActionState`]: useBoundActionState,
2588
+ [`use${prefix}Persist`]: useBoundPersist
2368
2589
  };
2369
2590
  };
2370
2591
  var useFieldA11y = (error) => {