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