formstand 0.9.0 → 0.11.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
@@ -20,6 +20,20 @@ var moveFromTo = (from, to) => (n) => {
20
20
  return n;
21
21
  };
22
22
  var swapIndices = (a, b) => (n) => n === a ? b : n === b ? a : n;
23
+ var invertMapper = (mapper, oldLength, newLength) => {
24
+ const oldByNew = new Map(
25
+ Array.from({ length: oldLength }, (_, oldIndex) => oldIndex).flatMap(
26
+ (oldIndex) => {
27
+ const newIndex = mapper(oldIndex);
28
+ return newIndex === null ? [] : [[newIndex, oldIndex]];
29
+ }
30
+ )
31
+ );
32
+ return Array.from(
33
+ { length: newLength },
34
+ (_, newIndex) => oldByNew.get(newIndex) ?? -1
35
+ );
36
+ };
23
37
  var indexAndTail = (rest) => {
24
38
  const dotIdx = rest.indexOf(".");
25
39
  const idxStr = dotIdx === -1 ? rest : rest.slice(0, dotIdx);
@@ -43,6 +57,53 @@ var reKeyByArrayPath = (map, basePath, mapper) => {
43
57
  );
44
58
  };
45
59
 
60
+ // src/core/arrayOpLog.ts
61
+ var OP_LOG_LIMIT = 16;
62
+ var MAX_LOG_PATHS = 256;
63
+ var composeOps = (first, second) => ({
64
+ from: first.from,
65
+ to: second.to,
66
+ mapping: second.mapping.map(
67
+ (mid) => mid === -1 ? -1 : first.mapping[mid] ?? -1
68
+ )
69
+ });
70
+ var logs = /* @__PURE__ */ new WeakMap();
71
+ var boundOps = (ops) => {
72
+ const [head, second] = ops;
73
+ if (ops.length <= OP_LOG_LIMIT || head === void 0 || second === void 0) {
74
+ return ops;
75
+ }
76
+ return head.to === second.from ? [composeOps(head, second), ...ops.slice(2)] : ops.slice(1);
77
+ };
78
+ var recordArrayOp = (store, path, record) => {
79
+ const existing = logs.get(store);
80
+ const byPath = existing ?? /* @__PURE__ */ new Map();
81
+ if (existing === void 0) logs.set(store, byPath);
82
+ const bounded = boundOps([...byPath.get(path) ?? [], record]);
83
+ byPath.delete(path);
84
+ byPath.set(path, bounded);
85
+ if (byPath.size > MAX_LOG_PATHS) {
86
+ const oldest = byPath.keys().next().value;
87
+ if (oldest !== void 0) byPath.delete(oldest);
88
+ }
89
+ };
90
+ var arrayOpsFor = (store, path) => logs.get(store)?.get(path) ?? [];
91
+ var clearArrayOps = (store, path) => {
92
+ logs.get(store)?.delete(path);
93
+ };
94
+ var clearArrayOpsUnder = (store, path, keepSelf = false) => {
95
+ const byPath = logs.get(store);
96
+ if (byPath === void 0) return;
97
+ if (path === "") {
98
+ byPath.clear();
99
+ return;
100
+ }
101
+ const related = [...byPath.keys()].filter(
102
+ (p) => p === path && !keepSelf || p.startsWith(`${path}.`)
103
+ );
104
+ related.forEach((p) => byPath.delete(p));
105
+ };
106
+
46
107
  // src/core/equality.ts
47
108
  var isPlainObject = (v) => {
48
109
  if (typeof v !== "object" || v === null) return false;
@@ -178,7 +239,7 @@ var expandIssue = (issue, prefix) => {
178
239
  return [{ path: issuePathToFormPath(at), message: issue.message }];
179
240
  };
180
241
  var flattenIssues = (issues) => issues.flatMap((issue) => expandIssue(issue, [])).reduce((acc, { path, message }) => {
181
- const existing = acc[path] ?? [];
242
+ const existing = (Object.hasOwn(acc, path) ? acc[path] : void 0) ?? [];
182
243
  return existing.includes(message) ? acc : { ...acc, [path]: [...existing, message] };
183
244
  }, {});
184
245
  var validateSync = (schema, values) => {
@@ -311,7 +372,9 @@ var reuseErrorRefs = (next, prev) => {
311
372
  };
312
373
  var scopeSettledResult = (result, path, viaSubschema) => result.kind !== "invalid" ? emptyErrors : viaSubschema ? prefixErrorKeys(result.errors, path) : path === "" ? result.errors : pickScope(result.errors, [path]);
313
374
  var mergeErrorChannels = (schema, server) => {
314
- const extra = Object.entries(server).filter(([k]) => schema[k] === void 0);
375
+ const extra = Object.entries(server).filter(
376
+ ([k]) => !Object.hasOwn(schema, k)
377
+ );
315
378
  return extra.length === 0 ? schema : { ...schema, ...Object.fromEntries(extra) };
316
379
  };
317
380
  var errorChannels = (state, patch) => {
@@ -370,8 +433,11 @@ var createForm = (schema, options) => {
370
433
  schemaErrors: reuseErrorRefs(next, state.schemaErrors)
371
434
  });
372
435
  const validate = () => {
436
+ if (asyncScopes.has("")) {
437
+ return { kind: "pending", promise: validateAsyncOnForm() };
438
+ }
373
439
  try {
374
- const result = validateSync(schema, store.getState().values);
440
+ const result = validateSyncLatched("", schema, store.getState().values);
375
441
  store.setState((state) => ({
376
442
  ...state,
377
443
  ...commitFullPass(
@@ -388,25 +454,32 @@ var createForm = (schema, options) => {
388
454
  }
389
455
  };
390
456
  const validateFields = (paths) => {
391
- if (paths.length === 0) return true;
457
+ if (paths.length === 0) return { kind: "valid" };
392
458
  if (paths.some((p) => p === "")) {
393
459
  const result = validate();
394
- return result.kind === "pending" ? result.promise.then((r) => r.kind === "valid") : result.kind === "valid";
460
+ return result.kind === "pending" ? { kind: "pending", promise: result.promise.then(settledFormResult) } : settledFormResult(result);
461
+ }
462
+ const values = store.getState().values;
463
+ const scopes = paths.map((path) => ({
464
+ path,
465
+ scope: fieldScopeFor(path, values)
466
+ }));
467
+ const allViaSubschema = scopes.every(
468
+ ({ scope }) => scope.kind === "skip" || scope.kind === "parse" && scope.viaSubschema
469
+ );
470
+ const anyLatched = allViaSubschema ? scopes.some(
471
+ ({ path, scope }) => scope.kind === "parse" && asyncScopes.has(path)
472
+ ) : asyncScopes.has("");
473
+ if (anyLatched) {
474
+ return { kind: "pending", promise: validateFieldsAsync(paths) };
395
475
  }
396
476
  try {
397
- const values = store.getState().values;
398
- const scopes = paths.map((path) => ({
399
- path,
400
- scope: fieldScopeFor(path, values)
401
- }));
402
- if (scopes.every(
403
- ({ scope }) => scope.kind === "skip" || scope.kind === "parse" && scope.viaSubschema
404
- )) {
477
+ if (allViaSubschema) {
405
478
  const merged = scopes.reduce(
406
479
  (acc, { path, scope }) => scope.kind === "skip" ? acc : {
407
480
  ...acc,
408
481
  ...scopeSettledResult(
409
- validateSync(scope.schema, scope.value),
482
+ validateSyncLatched(path, scope.schema, scope.value),
410
483
  path,
411
484
  true
412
485
  )
@@ -415,37 +488,31 @@ var createForm = (schema, options) => {
415
488
  );
416
489
  store.setState((state) => ({
417
490
  ...state,
418
- ...errorChannels(state, {
419
- schemaErrors: reuseErrorRefs(
420
- mergeScopedErrors(state.schemaErrors, merged, paths),
421
- state.schemaErrors
422
- ),
423
- serverErrors: omitScope(state.serverErrors, paths)
424
- })
491
+ ...commitScopedErrors(state, merged, paths)
425
492
  }));
426
- return Object.values(merged).flat().length === 0;
493
+ return settledFields(merged);
427
494
  }
428
- const result = validateSync(schema, values);
495
+ const result = validateSyncLatched("", schema, values);
429
496
  const fullErrors = result.kind === "invalid" ? result.errors : emptyErrors;
497
+ const requestedErrors = pickScope(fullErrors, paths);
430
498
  store.setState((state) => ({
431
499
  ...state,
432
- ...errorChannels(state, {
433
- schemaErrors: reuseErrorRefs(
434
- mergeScopedErrors(
435
- state.schemaErrors,
436
- pickScope(fullErrors, paths),
437
- paths
438
- ),
439
- state.schemaErrors
440
- ),
441
- // A validation explicitly targeting these paths supersedes any
442
- // server verdict on them.
443
- serverErrors: omitScope(state.serverErrors, paths)
444
- })
500
+ ...commitScopedErrors(state, requestedErrors, paths)
445
501
  }));
446
- return Object.keys(fullErrors).every((k) => !inScopeOfAny(k, paths));
502
+ return settledFields(requestedErrors);
503
+ } catch (e) {
504
+ if (isAsyncRequiredError(e)) {
505
+ return { kind: "pending", promise: validateFieldsAsync(paths) };
506
+ }
507
+ throw e;
508
+ }
509
+ };
510
+ const asyncScopes = /* @__PURE__ */ new Set();
511
+ const validateSyncLatched = (key, scopeSchema, value) => {
512
+ try {
513
+ return validateSync(scopeSchema, value);
447
514
  } catch (e) {
448
- if (isAsyncRequiredError(e)) return validateFieldsAsync(paths);
515
+ if (isAsyncRequiredError(e)) asyncScopes.add(key);
449
516
  throw e;
450
517
  }
451
518
  };
@@ -475,15 +542,6 @@ var createForm = (schema, options) => {
475
542
  const slot = slotAtPath(values, path);
476
543
  return slot.exists ? { kind: "parse", schema: sub, value: slot.value, viaSubschema: true } : { kind: "skip" };
477
544
  };
478
- const scopedFieldErrors = (path) => {
479
- const values = store.getState().values;
480
- const scope = fieldScopeFor(path, values);
481
- return scope.kind === "skip" ? emptyErrors : scopeSettledResult(
482
- validateSync(scope.schema, scope.value),
483
- path,
484
- scope.viaSubschema
485
- );
486
- };
487
545
  const scopedFieldErrorsAsync = async (path, values) => {
488
546
  const scope = fieldScopeFor(path, values);
489
547
  return scope.kind === "skip" ? emptyErrors : scopeSettledResult(
@@ -496,17 +554,32 @@ var createForm = (schema, options) => {
496
554
  const messages = Object.values(scoped).flat();
497
555
  return messages.length === 0 ? { kind: "valid" } : { kind: "invalid", errors: messages };
498
556
  };
499
- const commitFieldErrors = (state, scoped, path) => path === "" ? commitFullPass(state, scoped) : errorChannels(state, {
557
+ const settledFields = (scoped) => Object.keys(scoped).length === 0 ? { kind: "valid" } : { kind: "invalid", errors: scoped };
558
+ const settledFormResult = (result) => settledFields(result.kind === "invalid" ? result.errors : emptyErrors);
559
+ const commitScopedErrors = (state, scoped, paths) => errorChannels(state, {
500
560
  schemaErrors: reuseErrorRefs(
501
- mergeScopedErrors(state.schemaErrors, scoped, [path]),
561
+ mergeScopedErrors(state.schemaErrors, scoped, paths),
502
562
  state.schemaErrors
503
563
  ),
504
- serverErrors: omitScope(state.serverErrors, [path])
564
+ serverErrors: omitScope(state.serverErrors, paths)
505
565
  });
566
+ const commitFieldErrors = (state, scoped, path) => path === "" ? commitFullPass(state, scoped) : commitScopedErrors(state, scoped, [path]);
506
567
  const validateField = (path) => {
507
568
  warnMissingSchemaPath("validateField", path);
569
+ const scope = fieldScopeFor(path, store.getState().values);
570
+ if (scope.kind === "parse" && asyncScopes.has(scope.viaSubschema ? path : "")) {
571
+ return { kind: "pending", promise: validateFieldAsync(path) };
572
+ }
508
573
  try {
509
- const scoped = scopedFieldErrors(path);
574
+ const scoped = scope.kind === "skip" ? emptyErrors : scopeSettledResult(
575
+ validateSyncLatched(
576
+ scope.viaSubschema ? path : "",
577
+ scope.schema,
578
+ scope.value
579
+ ),
580
+ path,
581
+ scope.viaSubschema
582
+ );
510
583
  store.setState((state) => ({
511
584
  ...state,
512
585
  ...commitFieldErrors(state, scoped, path)
@@ -559,10 +632,9 @@ var createForm = (schema, options) => {
559
632
  return result;
560
633
  };
561
634
  const validateFieldsAsync = async (paths) => {
562
- if (paths.length === 0) return true;
635
+ if (paths.length === 0) return { kind: "valid" };
563
636
  if (paths.some((p) => p === "")) {
564
- const result2 = await validateAsyncOnForm();
565
- return result2.kind === "valid";
637
+ return settledFormResult(await validateAsyncOnForm());
566
638
  }
567
639
  const passes = new Map(paths.map((p) => [p, claimPass(p)]));
568
640
  const valuesAtStart = store.getState().values;
@@ -575,9 +647,8 @@ var createForm = (schema, options) => {
575
647
  }));
576
648
  const result = await validateAsync(schema, valuesAtStart);
577
649
  const fullErrors = result.kind === "invalid" ? result.errors : emptyErrors;
578
- const requestedValid = Object.keys(fullErrors).every(
579
- (k) => !inScopeOfAny(k, paths)
580
- );
650
+ const requestedErrors = pickScope(fullErrors, paths);
651
+ const requested = settledFields(requestedErrors);
581
652
  const live = store.getState();
582
653
  const stillCurrent = paths.filter(
583
654
  (p) => sequences.get(p) === passes.get(p)
@@ -592,27 +663,21 @@ var createForm = (schema, options) => {
592
663
  )
593
664
  }));
594
665
  }
595
- return requestedValid;
666
+ return requested;
596
667
  }
597
668
  store.setState((state) => ({
598
669
  ...state,
599
- ...errorChannels(state, {
600
- schemaErrors: reuseErrorRefs(
601
- mergeScopedErrors(
602
- state.schemaErrors,
603
- pickScope(fullErrors, stillCurrent),
604
- stillCurrent
605
- ),
606
- state.schemaErrors
607
- ),
608
- serverErrors: omitScope(state.serverErrors, stillCurrent)
609
- }),
670
+ ...commitScopedErrors(
671
+ state,
672
+ pickScope(fullErrors, stillCurrent),
673
+ stillCurrent
674
+ ),
610
675
  isValidating: stillCurrent.reduce(
611
676
  (acc, p) => omitKey(acc, p),
612
677
  state.isValidating
613
678
  )
614
679
  }));
615
- return requestedValid;
680
+ return requested;
616
681
  };
617
682
  const validateFieldAsync = async (path) => {
618
683
  warnMissingSchemaPath("validateFieldAsync", path);
@@ -662,12 +727,21 @@ var createForm = (schema, options) => {
662
727
  );
663
728
  return;
664
729
  }
730
+ const arr = Array.isArray(current) ? current : [];
731
+ const next = nextArray(arr);
732
+ const written = setAtPath(store.getState().values, path, next);
733
+ clearArrayOpsUnder(store, path, true);
734
+ if (Array.isArray(current) && getAtPath(written, path) === next) {
735
+ recordArrayOp(store, path, {
736
+ from: current,
737
+ to: next,
738
+ mapping: invertMapper(mapper, current.length, next.length)
739
+ });
740
+ }
665
741
  store.setState((state) => {
666
- const live = getAtPath(state.values, path);
667
- const arr = Array.isArray(live) ? live : [];
668
742
  return {
669
743
  ...state,
670
- values: setAtPath(state.values, path, nextArray(arr)),
744
+ values: written,
671
745
  ...errorChannels(state, {
672
746
  schemaErrors: reKeyByArrayPath(state.schemaErrors, path, mapper),
673
747
  // Server entries on rows follow their rows through the index
@@ -808,24 +882,30 @@ var createForm = (schema, options) => {
808
882
  }
809
883
  });
810
884
  },
811
- setValue: (path, value) => store.setState((state) => ({
812
- ...state,
813
- values: setAtPath(state.values, path, value),
814
- ...errorChannels(state, {
815
- serverErrors: releaseSpine(state.serverErrors, path)
816
- })
817
- })),
818
- setValues: (next) => store.setState((state) => ({
819
- ...state,
820
- values: next,
821
- ...errorChannels(state, {
822
- serverErrors: releaseChangedSlices(
823
- state.serverErrors,
824
- state.values,
825
- next
826
- )
827
- })
828
- })),
885
+ setValue: (path, value) => {
886
+ clearArrayOpsUnder(store, path);
887
+ store.setState((state) => ({
888
+ ...state,
889
+ values: setAtPath(state.values, path, value),
890
+ ...errorChannels(state, {
891
+ serverErrors: releaseSpine(state.serverErrors, path)
892
+ })
893
+ }));
894
+ },
895
+ setValues: (next) => {
896
+ clearArrayOpsUnder(store, "");
897
+ store.setState((state) => ({
898
+ ...state,
899
+ values: next,
900
+ ...errorChannels(state, {
901
+ serverErrors: releaseChangedSlices(
902
+ state.serverErrors,
903
+ state.values,
904
+ next
905
+ )
906
+ })
907
+ }));
908
+ },
829
909
  setTouched: (path, touched = true) => store.setState((state) => ({
830
910
  ...state,
831
911
  touched: { ...state.touched, [path]: touched }
@@ -856,25 +936,32 @@ var createForm = (schema, options) => {
856
936
  })
857
937
  };
858
938
  }),
859
- updateState: (updater) => store.setState((state) => {
860
- const patch = updater(state);
861
- if (Object.keys(patch).length === 0) return state;
862
- if (patch.errors !== void 0 && patch.errors !== state.errors) {
863
- console.warn(
864
- "[formstand] `errors` is derived from schemaErrors/serverErrors \u2014 patch those channels instead; the direct `errors` patch is ignored."
865
- );
939
+ updateState: (updater) => {
940
+ const valuesBefore = store.getState().values;
941
+ store.setState((state) => {
942
+ const patch = updater(state);
943
+ if (Object.keys(patch).length === 0) return state;
944
+ if (patch.errors !== void 0 && patch.errors !== state.errors) {
945
+ console.warn(
946
+ "[formstand] `errors` is derived from schemaErrors/serverErrors \u2014 patch those channels instead; the direct `errors` patch is ignored."
947
+ );
948
+ }
949
+ const next = { ...state, ...patch };
950
+ return {
951
+ ...next,
952
+ ...errorChannels(state, {
953
+ schemaErrors: next.schemaErrors,
954
+ serverErrors: next.serverErrors
955
+ })
956
+ };
957
+ });
958
+ if (store.getState().values !== valuesBefore) {
959
+ clearArrayOpsUnder(store, "");
866
960
  }
867
- const next = { ...state, ...patch };
868
- return {
869
- ...next,
870
- ...errorChannels(state, {
871
- schemaErrors: next.schemaErrors,
872
- serverErrors: next.serverErrors
873
- })
874
- };
875
- }),
961
+ },
876
962
  adoptValues: (values) => {
877
963
  sequences.clear();
964
+ clearArrayOpsUnder(store, "");
878
965
  store.setState((state) => ({
879
966
  ...state,
880
967
  values,
@@ -892,6 +979,7 @@ var createForm = (schema, options) => {
892
979
  },
893
980
  reset: (nextInitial, resetOptions) => {
894
981
  sequences.clear();
982
+ clearArrayOpsUnder(store, "");
895
983
  store.setState((state) => {
896
984
  const init = nextInitial === void 0 ? state.initialValues : isPlainObject(state.initialValues) && isPlainObject(nextInitial) ? {
897
985
  ...state.initialValues,
@@ -913,23 +1001,33 @@ var createForm = (schema, options) => {
913
1001
  };
914
1002
  });
915
1003
  },
916
- resetField: (path) => store.setState((state) => ({
917
- ...state,
918
- values: setAtPath(
919
- state.values,
920
- path,
921
- getAtPath(state.initialValues, path)
922
- ),
923
- // A runtime "" path scopes the whole form (the imperative surface is
924
- // string-typed even though FieldPath excludes ""). Server entries
925
- // follow the setValue release contract (releaseSpine).
926
- ...errorChannels(state, {
927
- schemaErrors: path === "" ? emptyErrors : omitScope(state.schemaErrors, [path]),
928
- serverErrors: releaseSpine(state.serverErrors, path)
929
- }),
930
- touched: path === "" ? emptyBools : omitScope(state.touched, [path]),
931
- isValidating: path === "" ? emptyBools : omitScope(state.isValidating, [path])
932
- })),
1004
+ resetField: (path) => {
1005
+ const initialSlot = slotAtPath(
1006
+ store.getState().initialValues,
1007
+ path
1008
+ );
1009
+ if (!initialSlot.exists) {
1010
+ console.warn(
1011
+ `[formstand] resetField("${path}"): path has no initial value (e.g. an appended array row); value left unchanged, field state cleared.`
1012
+ );
1013
+ }
1014
+ if (initialSlot.exists) {
1015
+ clearArrayOpsUnder(store, path);
1016
+ }
1017
+ store.setState((state) => ({
1018
+ ...state,
1019
+ values: initialSlot.exists ? setAtPath(state.values, path, initialSlot.value) : state.values,
1020
+ // A runtime "" path scopes the whole form (the imperative surface is
1021
+ // string-typed even though FieldPath excludes ""). Server entries
1022
+ // follow the setValue release contract (releaseSpine).
1023
+ ...errorChannels(state, {
1024
+ schemaErrors: path === "" ? emptyErrors : omitScope(state.schemaErrors, [path]),
1025
+ serverErrors: releaseSpine(state.serverErrors, path)
1026
+ }),
1027
+ touched: path === "" ? emptyBools : omitScope(state.touched, [path]),
1028
+ isValidating: path === "" ? emptyBools : omitScope(state.isValidating, [path])
1029
+ }));
1030
+ },
933
1031
  validate,
934
1032
  validateField,
935
1033
  validateFields,
@@ -983,29 +1081,32 @@ var createForm = (schema, options) => {
983
1081
  return dirtyPathsOf(state.values, state.initialValues, "");
984
1082
  },
985
1083
  snapshot: () => store.getState(),
986
- restore: (snap) => store.setState(() => {
987
- const schemaErrors = snap.schemaErrors ?? emptyErrors;
988
- const serverErrors = snap.serverErrors ?? emptyErrors;
989
- return {
990
- ...snap,
991
- schemaErrors,
992
- serverErrors,
993
- errors: reuseErrorRefs(
994
- mergeErrorChannels(schemaErrors, serverErrors),
995
- snap.errors ?? emptyErrors
996
- ),
997
- // In-flight state is owned by live passes, never by snapshots: a
998
- // snapshotted flag has no pass left to clear it (the pass that set
999
- // it cleared its ORIGINAL entry when it settled), so restoring one
1000
- // would stick it forever. isSubmitting follows the same rule — a
1001
- // mid-submit snapshot restored later must not disable submit
1002
- // buttons forever, so it reflects the LIVE submit count instead of
1003
- // the snapshotted flag (a live pass's finally still resets it).
1004
- isValidating: emptyBools,
1005
- isValidatingForm: false,
1006
- isSubmitting: inFlight.count > 0
1007
- };
1008
- })
1084
+ restore: (snap) => {
1085
+ clearArrayOpsUnder(store, "");
1086
+ store.setState(() => {
1087
+ const schemaErrors = snap.schemaErrors ?? emptyErrors;
1088
+ const serverErrors = snap.serverErrors ?? emptyErrors;
1089
+ return {
1090
+ ...snap,
1091
+ schemaErrors,
1092
+ serverErrors,
1093
+ errors: reuseErrorRefs(
1094
+ mergeErrorChannels(schemaErrors, serverErrors),
1095
+ snap.errors ?? emptyErrors
1096
+ ),
1097
+ // In-flight state is owned by live passes, never by snapshots: a
1098
+ // snapshotted flag has no pass left to clear it (the pass that set
1099
+ // it cleared its ORIGINAL entry when it settled), so restoring one
1100
+ // would stick it forever. isSubmitting follows the same rule — a
1101
+ // mid-submit snapshot restored later must not disable submit
1102
+ // buttons forever, so it reflects the LIVE submit count instead of
1103
+ // the snapshotted flag (a live pass's finally still resets it).
1104
+ isValidating: emptyBools,
1105
+ isValidatingForm: false,
1106
+ isSubmitting: inFlight.count > 0
1107
+ };
1108
+ });
1109
+ }
1009
1110
  });
1010
1111
  };
1011
1112
 
@@ -1175,15 +1276,7 @@ function useField(form, pathArg, options) {
1175
1276
  }, debounceMs);
1176
1277
  return;
1177
1278
  }
1178
- try {
1179
- form.validateField(path);
1180
- } catch (e) {
1181
- if (isAsyncRequiredError(e)) {
1182
- void form.validateFieldAsync(path);
1183
- return;
1184
- }
1185
- throw e;
1186
- }
1279
+ form.validateField(path);
1187
1280
  }, [form, path, debounceMs]);
1188
1281
  const setValue = react.useCallback(
1189
1282
  (value) => {
@@ -1263,12 +1356,14 @@ function useVariantField(form, unionPath, field) {
1263
1356
  `${unionPath}.${field}`
1264
1357
  );
1265
1358
  }
1266
- var EMPTY_ID_STATE = { items: [], ids: [], counter: 0 };
1267
1359
  var EMPTY_ITEMS = [];
1360
+ var mintNumbers = (needsMint) => new Map(
1361
+ needsMint.flatMap((mint, i) => mint ? [i] : []).map((position, k) => [position, k + 1])
1362
+ );
1268
1363
  var reconcileIds = (prev, nextItems) => {
1269
1364
  if (prev.items === nextItems) return prev;
1270
1365
  if (prev.items.length === nextItems.length && prev.items.every((item, i) => Object.is(item, nextItems[i]))) {
1271
- return prev;
1366
+ return { ...prev, items: nextItems };
1272
1367
  }
1273
1368
  const buckets = prev.items.reduce((acc, item, i) => {
1274
1369
  const bucket = acc.get(item);
@@ -1290,15 +1385,77 @@ var reconcileIds = (prev, nextItems) => {
1290
1385
  })
1291
1386
  );
1292
1387
  const reused = matchedIds.map((id, i) => id ?? leftoverAt.get(i) ?? null);
1293
- const mintCounts = reused.reduce((acc, id) => {
1294
- const count = acc[acc.length - 1] ?? 0;
1295
- return [...acc, id === null ? count + 1 : count];
1296
- }, []);
1297
- const minted = mintCounts[mintCounts.length - 1] ?? 0;
1388
+ const mints = mintNumbers(reused.map((id) => id === null));
1298
1389
  const ids = reused.map(
1299
- (id, i) => id ?? `__zfa_${prev.counter + (mintCounts[i] ?? 0)}`
1390
+ (id, i) => id ?? `${prev.prefix}${prev.counter + (mints.get(i) ?? 0)}`
1391
+ );
1392
+ return {
1393
+ items: nextItems,
1394
+ ids,
1395
+ counter: prev.counter + mints.size,
1396
+ prefix: prev.prefix
1397
+ };
1398
+ };
1399
+ var mappedIdState = (prev, mapping, liveItems) => {
1400
+ if (liveItems.length !== mapping.length) return null;
1401
+ const aligned = mapping.every(
1402
+ (src, i) => src === -1 ? true : prev.ids[src] !== void 0 && Object.is(liveItems[i], prev.items[src])
1403
+ );
1404
+ if (!aligned) return null;
1405
+ const mints = mintNumbers(mapping.map((src) => src === -1));
1406
+ const ids = mapping.map(
1407
+ (src, i) => src === -1 ? `${prev.prefix}${prev.counter + (mints.get(i) ?? 0)}` : prev.ids[src]
1300
1408
  );
1301
- return { items: nextItems, ids, counter: prev.counter + minted };
1409
+ return {
1410
+ items: liveItems,
1411
+ ids,
1412
+ counter: prev.counter + mints.size,
1413
+ prefix: prev.prefix
1414
+ };
1415
+ };
1416
+ var MAX_ID_PATHS = 4096;
1417
+ var sharedIdEntries = /* @__PURE__ */ new WeakMap();
1418
+ var sharedIdEntry = (store, path) => {
1419
+ const existingStore = sharedIdEntries.get(store);
1420
+ const forStore = existingStore ?? { byPath: /* @__PURE__ */ new Map(), seq: 1 };
1421
+ if (existingStore === void 0) sharedIdEntries.set(store, forStore);
1422
+ const existing = forStore.byPath.get(path);
1423
+ if (existing !== void 0) {
1424
+ forStore.byPath.delete(path);
1425
+ forStore.byPath.set(path, existing);
1426
+ return existing;
1427
+ }
1428
+ const created = {
1429
+ state: {
1430
+ items: EMPTY_ITEMS,
1431
+ ids: [],
1432
+ counter: 0,
1433
+ prefix: `__zfa_${forStore.seq}_`
1434
+ }
1435
+ };
1436
+ forStore.seq += 1;
1437
+ forStore.byPath.set(path, created);
1438
+ if (forStore.byPath.size > MAX_ID_PATHS) {
1439
+ const oldest = forStore.byPath.keys().next().value;
1440
+ if (oldest !== void 0) forStore.byPath.delete(oldest);
1441
+ }
1442
+ return created;
1443
+ };
1444
+ var walkOps = (start, items, ops) => {
1445
+ const walked = ops.reduce((acc, op) => {
1446
+ const base = acc.items === op.from ? acc : reconcileIds(acc, op.from);
1447
+ return mappedIdState(base, op.mapping, op.to) ?? base;
1448
+ }, start);
1449
+ return walked.items === items ? walked : reconcileIds(walked, items);
1450
+ };
1451
+ var deriveIds = (store, path, items) => {
1452
+ const entry = sharedIdEntry(store, path);
1453
+ if (entry.state.items === items) return entry.state;
1454
+ const ops = arrayOpsFor(store, path);
1455
+ if (ops.length > 0) clearArrayOps(store, path);
1456
+ const next = walkOps(entry.state, items, ops);
1457
+ entry.state = next;
1458
+ return next;
1302
1459
  };
1303
1460
  function useFieldArray(form, pathArg) {
1304
1461
  const path = react$1.useStore(
@@ -1317,13 +1474,7 @@ function useFieldArray(form, pathArg) {
1317
1474
  );
1318
1475
  const items = slice.items;
1319
1476
  const error = slice.error;
1320
- const [idEntry, setIdEntry] = react.useState({ form, path, state: EMPTY_ID_STATE });
1321
- const base = idEntry.form === form && idEntry.path === path ? idEntry.state : { ...EMPTY_ID_STATE, counter: idEntry.state.counter };
1322
- const nextIdState = reconcileIds(base, items);
1323
- if (idEntry.form !== form || idEntry.path !== path || idEntry.state !== nextIdState) {
1324
- setIdEntry({ form, path, state: nextIdState });
1325
- }
1326
- const ids = nextIdState.ids;
1477
+ const ids = deriveIds(form.store, path, items).ids;
1327
1478
  const push = react.useCallback(
1328
1479
  (item) => form.arrayPush(path, item),
1329
1480
  [form, path]
@@ -1433,7 +1584,8 @@ var createFormHooks = (form, name) => {
1433
1584
  };
1434
1585
 
1435
1586
  // src/react/inputProps.ts
1436
- var ariaInvalid = (field) => field.error !== void 0 && field.error.length > 0 ? true : void 0;
1587
+ var hasFieldError = (error) => error !== void 0 && error.length > 0;
1588
+ var ariaInvalid = (field) => hasFieldError(field.error) ? true : void 0;
1437
1589
  var numberToInputText = (value) => value === void 0 || value === null || Number.isNaN(value) ? "" : String(value);
1438
1590
  var parseNumberText = (text) => {
1439
1591
  if (text.trim() === "") return { kind: "empty" };
@@ -1527,11 +1679,37 @@ var selectProps = (field) => ({
1527
1679
  },
1528
1680
  onBlur: field.onBlur
1529
1681
  });
1530
- var hasError = (error) => error !== void 0 && error.length > 0;
1682
+ var useFieldA11y = (error) => {
1683
+ const id = react.useId();
1684
+ const errorId = `${id}-error`;
1685
+ return {
1686
+ id,
1687
+ errorId,
1688
+ describedBy: hasFieldError(error) ? errorId : void 0
1689
+ };
1690
+ };
1531
1691
  var ErrorText = ({
1532
1692
  id,
1533
1693
  error
1534
- }) => hasError(error) ? /* @__PURE__ */ jsxRuntime.jsx("span", { className: "zf-error", id, role: "alert", children: error?.[0] }) : null;
1694
+ }) => hasFieldError(error) ? /* @__PURE__ */ jsxRuntime.jsx("span", { className: "zf-error", id, role: "alert", children: error?.[0] }) : null;
1695
+ var FieldShell = ({
1696
+ id,
1697
+ errorId,
1698
+ label,
1699
+ error,
1700
+ wrapLabel,
1701
+ children
1702
+ }) => /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "zf-field", children: [
1703
+ wrapLabel === true ? /* @__PURE__ */ jsxRuntime.jsxs("label", { htmlFor: id, className: "zf-label", children: [
1704
+ children,
1705
+ " ",
1706
+ label
1707
+ ] }) : /* @__PURE__ */ jsxRuntime.jsxs(jsxRuntime.Fragment, { children: [
1708
+ label !== void 0 ? /* @__PURE__ */ jsxRuntime.jsx("label", { htmlFor: id, className: "zf-label", children: label }) : null,
1709
+ children
1710
+ ] }),
1711
+ /* @__PURE__ */ jsxRuntime.jsx(ErrorText, { id: errorId, error })
1712
+ ] });
1535
1713
  var TextField = ({
1536
1714
  form,
1537
1715
  path,
@@ -1541,25 +1719,29 @@ var TextField = ({
1541
1719
  autoComplete,
1542
1720
  ref
1543
1721
  }) => {
1544
- const id = react.useId();
1545
- const errorId = `${id}-error`;
1546
1722
  const field = useField(form, path);
1547
- return /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "zf-field", children: [
1548
- label !== void 0 ? /* @__PURE__ */ jsxRuntime.jsx("label", { htmlFor: id, className: "zf-label", children: label }) : null,
1549
- /* @__PURE__ */ jsxRuntime.jsx(
1550
- "input",
1551
- {
1552
- id,
1553
- ref,
1554
- ...textInputProps(field),
1555
- type,
1556
- placeholder,
1557
- autoComplete,
1558
- "aria-describedby": hasError(field.error) ? errorId : void 0
1559
- }
1560
- ),
1561
- /* @__PURE__ */ jsxRuntime.jsx(ErrorText, { id: errorId, error: field.error })
1562
- ] });
1723
+ const a11y = useFieldA11y(field.error);
1724
+ return /* @__PURE__ */ jsxRuntime.jsx(
1725
+ FieldShell,
1726
+ {
1727
+ id: a11y.id,
1728
+ errorId: a11y.errorId,
1729
+ label,
1730
+ error: field.error,
1731
+ children: /* @__PURE__ */ jsxRuntime.jsx(
1732
+ "input",
1733
+ {
1734
+ id: a11y.id,
1735
+ ref,
1736
+ ...textInputProps(field),
1737
+ type,
1738
+ placeholder,
1739
+ autoComplete,
1740
+ "aria-describedby": a11y.describedBy
1741
+ }
1742
+ )
1743
+ }
1744
+ );
1563
1745
  };
1564
1746
  var DateField = ({
1565
1747
  form,
@@ -1569,24 +1751,28 @@ var DateField = ({
1569
1751
  max,
1570
1752
  ref
1571
1753
  }) => {
1572
- const id = react.useId();
1573
- const errorId = `${id}-error`;
1574
1754
  const field = useField(form, path);
1575
- return /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "zf-field", children: [
1576
- label !== void 0 ? /* @__PURE__ */ jsxRuntime.jsx("label", { htmlFor: id, className: "zf-label", children: label }) : null,
1577
- /* @__PURE__ */ jsxRuntime.jsx(
1578
- "input",
1579
- {
1580
- id,
1581
- ref,
1582
- ...dateInputProps(field),
1583
- min,
1584
- max,
1585
- "aria-describedby": hasError(field.error) ? errorId : void 0
1586
- }
1587
- ),
1588
- /* @__PURE__ */ jsxRuntime.jsx(ErrorText, { id: errorId, error: field.error })
1589
- ] });
1755
+ const a11y = useFieldA11y(field.error);
1756
+ return /* @__PURE__ */ jsxRuntime.jsx(
1757
+ FieldShell,
1758
+ {
1759
+ id: a11y.id,
1760
+ errorId: a11y.errorId,
1761
+ label,
1762
+ error: field.error,
1763
+ children: /* @__PURE__ */ jsxRuntime.jsx(
1764
+ "input",
1765
+ {
1766
+ id: a11y.id,
1767
+ ref,
1768
+ ...dateInputProps(field),
1769
+ min,
1770
+ max,
1771
+ "aria-describedby": a11y.describedBy
1772
+ }
1773
+ )
1774
+ }
1775
+ );
1590
1776
  };
1591
1777
  var IDLE_EDIT = { raw: null, pushed: void 0 };
1592
1778
  var useNumberInput = (field) => {
@@ -1600,7 +1786,7 @@ var useNumberInput = (field) => {
1600
1786
  name: field.path,
1601
1787
  value: raw ?? numberToInputText(field.value),
1602
1788
  inputMode: "decimal",
1603
- "aria-invalid": hasError(field.error) ? true : void 0,
1789
+ "aria-invalid": hasFieldError(field.error) ? true : void 0,
1604
1790
  onChange: (e) => {
1605
1791
  const text = e.target.value;
1606
1792
  const parsed = parseNumberText(text);
@@ -1633,25 +1819,29 @@ var NumberField = ({
1633
1819
  placeholder,
1634
1820
  ref
1635
1821
  }) => {
1636
- const id = react.useId();
1637
- const errorId = `${id}-error`;
1638
1822
  const field = useField(form, path);
1639
1823
  const binding = useNumberInput(field);
1640
- return /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "zf-field", children: [
1641
- label !== void 0 ? /* @__PURE__ */ jsxRuntime.jsx("label", { htmlFor: id, className: "zf-label", children: label }) : null,
1642
- /* @__PURE__ */ jsxRuntime.jsx(
1643
- "input",
1644
- {
1645
- id,
1646
- ref,
1647
- type: "text",
1648
- ...binding,
1649
- placeholder,
1650
- "aria-describedby": hasError(field.error) ? errorId : void 0
1651
- }
1652
- ),
1653
- /* @__PURE__ */ jsxRuntime.jsx(ErrorText, { id: errorId, error: field.error })
1654
- ] });
1824
+ const a11y = useFieldA11y(field.error);
1825
+ return /* @__PURE__ */ jsxRuntime.jsx(
1826
+ FieldShell,
1827
+ {
1828
+ id: a11y.id,
1829
+ errorId: a11y.errorId,
1830
+ label,
1831
+ error: field.error,
1832
+ children: /* @__PURE__ */ jsxRuntime.jsx(
1833
+ "input",
1834
+ {
1835
+ id: a11y.id,
1836
+ ref,
1837
+ type: "text",
1838
+ ...binding,
1839
+ placeholder,
1840
+ "aria-describedby": a11y.describedBy
1841
+ }
1842
+ )
1843
+ }
1844
+ );
1655
1845
  };
1656
1846
  var CheckboxField = ({
1657
1847
  form,
@@ -1659,25 +1849,27 @@ var CheckboxField = ({
1659
1849
  label,
1660
1850
  ref
1661
1851
  }) => {
1662
- const id = react.useId();
1663
- const errorId = `${id}-error`;
1664
1852
  const field = useField(form, path);
1665
- return /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "zf-field", children: [
1666
- /* @__PURE__ */ jsxRuntime.jsxs("label", { htmlFor: id, className: "zf-label", children: [
1667
- /* @__PURE__ */ jsxRuntime.jsx(
1853
+ const a11y = useFieldA11y(field.error);
1854
+ return /* @__PURE__ */ jsxRuntime.jsx(
1855
+ FieldShell,
1856
+ {
1857
+ id: a11y.id,
1858
+ errorId: a11y.errorId,
1859
+ label,
1860
+ error: field.error,
1861
+ wrapLabel: true,
1862
+ children: /* @__PURE__ */ jsxRuntime.jsx(
1668
1863
  "input",
1669
1864
  {
1670
- id,
1865
+ id: a11y.id,
1671
1866
  ref,
1672
1867
  ...checkboxProps(field),
1673
- "aria-describedby": hasError(field.error) ? errorId : void 0
1868
+ "aria-describedby": a11y.describedBy
1674
1869
  }
1675
- ),
1676
- " ",
1677
- label
1678
- ] }),
1679
- /* @__PURE__ */ jsxRuntime.jsx(ErrorText, { id: errorId, error: field.error })
1680
- ] });
1870
+ )
1871
+ }
1872
+ );
1681
1873
  };
1682
1874
  var SelectField = ({
1683
1875
  form,
@@ -1687,33 +1879,39 @@ var SelectField = ({
1687
1879
  placeholder,
1688
1880
  ref
1689
1881
  }) => {
1690
- const id = react.useId();
1691
- const errorId = `${id}-error`;
1692
1882
  const field = useField(form, path);
1883
+ const a11y = useFieldA11y(field.error);
1884
+ const select = selectProps(field);
1693
1885
  const clearable = field.emptyValue === null;
1694
- const showEmptyOption = clearable || field.value === void 0 || field.value === null || placeholder !== void 0;
1695
- return /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "zf-field", children: [
1696
- label !== void 0 ? /* @__PURE__ */ jsxRuntime.jsx("label", { htmlFor: id, className: "zf-label", children: label }) : null,
1697
- /* @__PURE__ */ jsxRuntime.jsxs(
1698
- "select",
1699
- {
1700
- id,
1701
- ref,
1702
- ...selectProps(field),
1703
- "aria-describedby": hasError(field.error) ? errorId : void 0,
1704
- children: [
1705
- showEmptyOption ? /* @__PURE__ */ jsxRuntime.jsx("option", { value: "", disabled: !clearable, children: placeholder ?? "" }) : null,
1706
- options.map((opt, index) => (
1707
- // Index-qualified: option lists may legitimately repeat a value
1708
- // (e.g. differently-labelled aliases), and bare value keys would
1709
- // collide.
1710
- /* @__PURE__ */ jsxRuntime.jsx("option", { value: opt.value, children: opt.label }, `${index}-${opt.value}`)
1711
- ))
1712
- ]
1713
- }
1714
- ),
1715
- /* @__PURE__ */ jsxRuntime.jsx(ErrorText, { id: errorId, error: field.error })
1716
- ] });
1886
+ const hasExplicitEmptyOption = options.some((opt) => opt.value === "");
1887
+ const showEmptyOption = !hasExplicitEmptyOption && (clearable || select.value === "" || placeholder !== void 0);
1888
+ return /* @__PURE__ */ jsxRuntime.jsx(
1889
+ FieldShell,
1890
+ {
1891
+ id: a11y.id,
1892
+ errorId: a11y.errorId,
1893
+ label,
1894
+ error: field.error,
1895
+ children: /* @__PURE__ */ jsxRuntime.jsxs(
1896
+ "select",
1897
+ {
1898
+ id: a11y.id,
1899
+ ref,
1900
+ ...select,
1901
+ "aria-describedby": a11y.describedBy,
1902
+ children: [
1903
+ showEmptyOption ? /* @__PURE__ */ jsxRuntime.jsx("option", { value: "", disabled: !clearable, children: placeholder ?? "" }) : null,
1904
+ options.map((opt, index) => (
1905
+ // Index-qualified: option lists may legitimately repeat a value
1906
+ // (e.g. differently-labelled aliases), and bare value keys would
1907
+ // collide.
1908
+ /* @__PURE__ */ jsxRuntime.jsx("option", { value: opt.value, children: opt.label }, `${index}-${opt.value}`)
1909
+ ))
1910
+ ]
1911
+ }
1912
+ )
1913
+ }
1914
+ );
1717
1915
  };
1718
1916
 
1719
1917
  // src/react/focusError.ts
@@ -1736,6 +1934,20 @@ var nameMatchesAny = (el, paths) => {
1736
1934
  const name = el.getAttribute("name");
1737
1935
  return name !== null && paths.some((p) => isPathOrChild(name, p));
1738
1936
  };
1937
+ var cssQuoted = (value) => `"${value.replace(/[\\"]/g, (ch) => `\\${ch}`)}"`;
1938
+ var idMatches = (scope, path) => path === "" ? [] : [...scope.querySelectorAll(`[id=${cssQuoted(path)}]`)];
1939
+ var inDomOrder = (els) => [...els].sort(
1940
+ (a, b) => a === b ? 0 : (a.compareDocumentPosition(b) & Node.DOCUMENT_POSITION_FOLLOWING) !== 0 ? -1 : 1
1941
+ );
1942
+ var pathCandidates = (scope, paths) => {
1943
+ const controls = namedControls(scope);
1944
+ const named = controls.filter((el) => nameMatchesAny(el, paths));
1945
+ const unnamedPaths = paths.filter(
1946
+ (p) => !controls.some((el) => nameMatchesAny(el, [p]))
1947
+ );
1948
+ const byId = unnamedPaths.flatMap((p) => idMatches(scope, p));
1949
+ return inDomOrder([.../* @__PURE__ */ new Set([...named, ...byId])]);
1950
+ };
1739
1951
  var focusFirstError = (errors, root) => {
1740
1952
  const scope = root ?? document;
1741
1953
  const erroredPaths = Object.keys(errors).filter(
@@ -1743,16 +1955,16 @@ var focusFirstError = (errors, root) => {
1743
1955
  );
1744
1956
  const hasRootError = (errors[""]?.length ?? 0) > 0;
1745
1957
  const controls = namedControls(scope);
1746
- const fieldMatches = controls.filter((el) => nameMatchesAny(el, erroredPaths)).filter(isFocusCandidate);
1958
+ const fieldMatches = pathCandidates(scope, erroredPaths).filter(
1959
+ isFocusCandidate
1960
+ );
1747
1961
  const rootFallbackApplies = hasRootError && (root !== void 0 || document.querySelectorAll("form").length <= 1);
1748
1962
  const candidates = fieldMatches.length > 0 ? fieldMatches : rootFallbackApplies ? controls.filter(isFocusCandidate) : [];
1749
1963
  return focusFirstOf(candidates);
1750
1964
  };
1751
1965
  var focusField = (path, root) => {
1752
- const controls = namedControls(root ?? document);
1753
- return path === "" ? (root !== void 0 || document.querySelectorAll("form").length <= 1) && focusFirstOf(controls.filter(isFocusCandidate)) : focusFirstOf(
1754
- controls.filter((el) => nameMatchesAny(el, [path])).filter(isFocusCandidate)
1755
- );
1966
+ const scope = root ?? document;
1967
+ return path === "" ? (root !== void 0 || document.querySelectorAll("form").length <= 1) && focusFirstOf(namedControls(scope).filter(isFocusCandidate)) : focusFirstOf(pathCandidates(scope, [path]).filter(isFocusCandidate));
1756
1968
  };
1757
1969
 
1758
1970
  exports.CheckboxField = CheckboxField;
@@ -1771,6 +1983,7 @@ exports.flattenIssues = flattenIssues;
1771
1983
  exports.focusField = focusField;
1772
1984
  exports.focusFirstError = focusFirstError;
1773
1985
  exports.getAtPath = getAtPath;
1986
+ exports.hasFieldError = hasFieldError;
1774
1987
  exports.isAsyncRequiredError = isAsyncRequiredError;
1775
1988
  exports.numberInputProps = numberInputProps;
1776
1989
  exports.numberToInputText = numberToInputText;
@@ -1791,6 +2004,7 @@ exports.useFormSelectorShallow = useFormSelectorShallow;
1791
2004
  exports.useIsDirty = useIsDirty;
1792
2005
  exports.useIsSubmitting = useIsSubmitting;
1793
2006
  exports.useIsValid = useIsValid;
2007
+ exports.useNumberInput = useNumberInput;
1794
2008
  exports.useSubmitCount = useSubmitCount;
1795
2009
  exports.useVariantField = useVariantField;
1796
2010
  exports.validateAsync = validateAsync;