formstand 0.21.0 → 0.22.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,7 +1,7 @@
1
- import { getAtPath, slotAtPath, setAtPath, isPlainObject, hasOwnSlot, valuesEqual, useFormSelectorShallow, isFieldDirty, useFormSelector, parsePath } from './chunk-OB23TUKY.js';
2
- export { getAtPath, parsePath, setAtPath, useFormSelector, useFormSelectorShallow } from './chunk-OB23TUKY.js';
3
- import { shouldValidateOn } from './chunk-MQ6262S3.js';
4
- export { shouldValidateOn } from './chunk-MQ6262S3.js';
1
+ import { isDevToolsBuild, getAtPath, isDevBuild, slotAtPath, setAtPath, isPlainObject, hasOwnSlot, valuesEqual, useFormSelectorShallow, isFieldDirty, useFormSelector, parsePath } from './chunk-BMXIZEI4.js';
2
+ export { getAtPath, parsePath, setAtPath, useFormSelector, useFormSelectorShallow } from './chunk-BMXIZEI4.js';
3
+ import { flagAt, entryAt, shouldValidateOn } from './chunk-V3PDMTPP.js';
4
+ export { shouldValidateOn } from './chunk-V3PDMTPP.js';
5
5
  import { createStore } from 'zustand/vanilla';
6
6
  import { devtools } from 'zustand/middleware';
7
7
  import { z } from 'zod';
@@ -114,10 +114,17 @@ var expandIssue = (issue, prefix) => {
114
114
  }
115
115
  return [{ path: issuePathToFormPath(at), message: issue.message }];
116
116
  };
117
- var flattenIssues = (issues) => issues.flatMap((issue) => expandIssue(issue, [])).reduce((acc, { path, message }) => {
118
- const existing = (Object.hasOwn(acc, path) ? acc[path] : void 0) ?? [];
119
- return existing.includes(message) ? acc : { ...acc, [path]: [...existing, message] };
120
- }, {});
117
+ var flattenIssues = (issues) => Object.fromEntries(
118
+ issues.flatMap((issue) => expandIssue(issue, [])).reduce((acc, { path, message }) => {
119
+ const existing = acc.get(path);
120
+ if (existing === void 0) {
121
+ acc.set(path, [message]);
122
+ } else if (!existing.includes(message)) {
123
+ existing.push(message);
124
+ }
125
+ return acc;
126
+ }, /* @__PURE__ */ new Map())
127
+ );
121
128
  var validateSync = (schema, values) => {
122
129
  const result = schema.safeParse(values);
123
130
  return result.success ? { kind: "valid", data: result.data } : { kind: "invalid", errors: flattenIssues(result.error.issues) };
@@ -182,6 +189,34 @@ var hasChecks = (s) => {
182
189
  const checks = s.def.checks;
183
190
  return checks !== void 0 && checks.length > 0;
184
191
  };
192
+ var fieldSchemaForIntrospection = (schema, path) => {
193
+ const childOf = (s, segment) => {
194
+ const inner = unwrapForWalk(s);
195
+ if (inner !== null) return childOf(inner, segment);
196
+ if (s instanceof z.ZodObject && typeof segment === "string") {
197
+ const shape = s.shape;
198
+ return Object.hasOwn(shape, segment) ? shape[segment] ?? null : null;
199
+ }
200
+ if (s instanceof z.ZodArray && typeof segment === "number") {
201
+ return s.element;
202
+ }
203
+ if (s instanceof z.ZodTuple && typeof segment === "number") {
204
+ return s.def.items[segment] ?? null;
205
+ }
206
+ if (s instanceof z.ZodDiscriminatedUnion || s instanceof z.ZodUnion) {
207
+ const options = s.def.options;
208
+ return options.reduce(
209
+ (found, option) => found ?? childOf(option, segment),
210
+ null
211
+ );
212
+ }
213
+ return null;
214
+ };
215
+ return parsePath(path).reduce(
216
+ (current, segment) => current === null ? null : childOf(current, segment),
217
+ schema
218
+ );
219
+ };
185
220
  var fieldSchemaAtPath = (schema, path) => parsePath(path).reduce((current, segment) => {
186
221
  if (current === null || hasChecks(current)) return null;
187
222
  if (current instanceof z.ZodObject && typeof segment === "string") {
@@ -200,6 +235,12 @@ var emptyErrors = {};
200
235
  var emptyBools = {};
201
236
  var identityMapper = (n) => n;
202
237
  var omitKey = (map, key) => Object.fromEntries(Object.entries(map).filter(([k]) => k !== key));
238
+ var omitKeys = (map, keys) => {
239
+ if (keys.size === 0) return map;
240
+ const entries = Object.entries(map);
241
+ const kept = entries.filter(([k]) => !keys.has(k));
242
+ return kept.length === entries.length ? map : Object.fromEntries(kept);
243
+ };
203
244
  var inScopeOfAny = (key, paths) => paths.some((p) => isPathOrChild(key, p));
204
245
  var omitScope = (map, paths) => {
205
246
  const entries = Object.entries(map);
@@ -238,13 +279,13 @@ var stringArraysEqual = (a, b) => a === b || a.length === b.length && a.every((v
238
279
  var reuseErrorRefs = (next, prev) => {
239
280
  if (next === prev) return prev;
240
281
  const entries = Object.entries(next).map(([k, v]) => {
241
- const old = prev[k];
282
+ const old = entryAt(prev, k);
242
283
  return [
243
284
  k,
244
285
  old !== void 0 && stringArraysEqual(old, v) ? old : v
245
286
  ];
246
287
  });
247
- const sameShape = entries.length === Object.keys(prev).length && entries.every(([k, v]) => prev[k] === v);
288
+ const sameShape = entries.length === Object.keys(prev).length && entries.every(([k, v]) => entryAt(prev, k) === v);
248
289
  return sameShape ? prev : Object.fromEntries(entries);
249
290
  };
250
291
  var scopeSettledResult = (result, path, viaSubschema) => result.kind !== "invalid" ? emptyErrors : viaSubschema ? prefixErrorKeys(result.errors, path) : path === "" ? result.errors : pickScope(result.errors, [path]);
@@ -301,9 +342,10 @@ var createForm = (schema, options) => {
301
342
  // opting in during development must NOT stream every keystroke to
302
343
  // an end user who happens to have the extension installed, where
303
344
  // a module-singleton form's unbounded time-travel history is a
304
- // real memory + serialization cost. Guarded for environments
305
- // without process (a bundler defines NODE_ENV in real dev).
306
- enabled: typeof process !== "undefined" && process.env?.["NODE_ENV"] !== "production"
345
+ // real memory + serialization cost. Fail-closed gate: a runtime
346
+ // without a readable NODE_ENV (CDN/ESM, Deno, a worker) reads as
347
+ // production here, unlike the console-warning gate.
348
+ enabled: isDevToolsBuild()
307
349
  })
308
350
  );
309
351
  const commitFullPass = (state, next) => errorChannels(state, {
@@ -419,13 +461,19 @@ var createForm = (schema, options) => {
419
461
  const slot = slotAtPath(values, path);
420
462
  return slot.exists ? { kind: "parse", schema: sub, value: slot.value, viaSubschema: true } : { kind: "skip" };
421
463
  };
422
- const scopedFieldErrorsAsync = async (path, values) => {
423
- const scope = fieldScopeFor(path, values);
424
- return scope.kind === "skip" ? emptyErrors : scopeSettledResult(
425
- await validateAsync(scope.schema, scope.value),
426
- path,
427
- scope.viaSubschema
428
- );
464
+ const parseScopeAsync = async (path, scope) => scope.kind === "skip" ? emptyErrors : scopeSettledResult(
465
+ await validateAsync(scope.schema, scope.value),
466
+ path,
467
+ scope.viaSubschema
468
+ );
469
+ const scopeStale = (scope, path, valuesAtStart, live) => {
470
+ if (live === valuesAtStart) return false;
471
+ switch (scope.kind) {
472
+ case "skip":
473
+ return slotAtPath(live, path).exists;
474
+ case "parse":
475
+ return scope.viaSubschema ? !Object.is(getAtPath(live, path), scope.value) : true;
476
+ }
429
477
  };
430
478
  const fieldResult = (scoped) => {
431
479
  const messages = Object.values(scoped).flat();
@@ -473,10 +521,10 @@ var createForm = (schema, options) => {
473
521
  const value = getAtPath(state.values, path);
474
522
  return {
475
523
  value,
476
- error: state.errors[path],
477
- touched: state.touched[path] ?? false,
524
+ error: entryAt(state.errors, path),
525
+ touched: flagAt(state.touched, path),
478
526
  dirty: isFieldDirty(value, getAtPath(state.initialValues, path)),
479
- isValidating: state.isValidating[path] ?? false
527
+ isValidating: flagAt(state.isValidating, path)
480
528
  };
481
529
  };
482
530
  const sequences = /* @__PURE__ */ new Map();
@@ -515,44 +563,66 @@ var createForm = (schema, options) => {
515
563
  }
516
564
  const passes = new Map(paths.map((p) => [p, claimPass(p)]));
517
565
  const valuesAtStart = store.getState().values;
566
+ const scopes = paths.map((path) => ({
567
+ path,
568
+ scope: fieldScopeFor(path, valuesAtStart)
569
+ }));
570
+ const allViaSubschema = scopes.every(
571
+ ({ scope }) => scope.kind === "skip" || scope.kind === "parse" && scope.viaSubschema
572
+ );
518
573
  store.setState((state) => ({
519
574
  ...state,
520
- isValidating: paths.reduce(
521
- (acc, p) => ({ ...acc, [p]: true }),
522
- { ...state.isValidating }
523
- )
575
+ isValidating: {
576
+ ...state.isValidating,
577
+ ...Object.fromEntries(paths.map((p) => [p, true]))
578
+ }
524
579
  }));
525
- const result = await validateAsync(schema, valuesAtStart);
526
- const fullErrors = result.kind === "invalid" ? result.errors : emptyErrors;
527
- const requestedErrors = pickScope(fullErrors, paths);
580
+ const parsed = allViaSubschema ? {
581
+ kind: "slots",
582
+ byPath: new Map(
583
+ await Promise.all(
584
+ scopes.map(
585
+ async ({ path, scope }) => [path, await parseScopeAsync(path, scope)]
586
+ )
587
+ )
588
+ )
589
+ } : {
590
+ kind: "full",
591
+ errors: await validateAsync(schema, valuesAtStart).then(
592
+ (result) => result.kind === "invalid" ? result.errors : emptyErrors
593
+ )
594
+ };
595
+ const errorsFor = (path) => {
596
+ switch (parsed.kind) {
597
+ case "slots":
598
+ return parsed.byPath.get(path) ?? emptyErrors;
599
+ case "full":
600
+ return pickScope(parsed.errors, [path]);
601
+ }
602
+ };
603
+ const requestedErrors = Object.fromEntries(
604
+ paths.flatMap((path) => Object.entries(errorsFor(path)))
605
+ );
528
606
  const requested = settledFields(requestedErrors);
529
607
  const live = store.getState();
530
- const stillCurrent = paths.filter(
531
- (p) => sequences.get(p) === passes.get(p)
608
+ const owned = scopes.filter(
609
+ ({ path }) => sequences.get(path) === passes.get(path)
532
610
  );
533
- if (live.values !== valuesAtStart) {
534
- if (stillCurrent.length > 0) {
535
- store.setState((state) => ({
536
- ...state,
537
- isValidating: stillCurrent.reduce(
538
- (acc, p) => omitKey(acc, p),
539
- state.isValidating
540
- )
541
- }));
542
- }
543
- return requested;
544
- }
611
+ const fresh = owned.filter(
612
+ ({ path, scope }) => !scopeStale(scope, path, valuesAtStart, live.values)
613
+ ).map(({ path }) => path);
614
+ const ownedKeys = new Set(owned.map(({ path }) => path));
615
+ if (owned.length === 0) return requested;
545
616
  store.setState((state) => ({
546
617
  ...state,
547
- ...commitScopedErrors(
618
+ ...fresh.length === 0 ? {} : commitScopedErrors(
548
619
  state,
549
- pickScope(fullErrors, stillCurrent),
550
- stillCurrent
620
+ Object.fromEntries(
621
+ fresh.flatMap((path) => Object.entries(errorsFor(path)))
622
+ ),
623
+ fresh
551
624
  ),
552
- isValidating: stillCurrent.reduce(
553
- (acc, p) => omitKey(acc, p),
554
- state.isValidating
555
- )
625
+ isValidating: omitKeys(state.isValidating, ownedKeys)
556
626
  }));
557
627
  return requested;
558
628
  };
@@ -566,14 +636,15 @@ var createForm = (schema, options) => {
566
636
  }
567
637
  const pass = claimPass(path);
568
638
  const valuesAtStart = store.getState().values;
639
+ const scope = fieldScopeFor(path, valuesAtStart);
569
640
  store.setState((state) => ({
570
641
  ...state,
571
642
  isValidating: { ...state.isValidating, [path]: true }
572
643
  }));
573
- const scoped = await scopedFieldErrorsAsync(path, valuesAtStart);
644
+ const scoped = await parseScopeAsync(path, scope);
574
645
  const live = store.getState();
575
646
  const stillOwns = sequences.get(path) === pass;
576
- if (!stillOwns || live.values !== valuesAtStart) {
647
+ if (!stillOwns || scopeStale(scope, path, valuesAtStart, live.values)) {
577
648
  if (stillOwns) {
578
649
  store.setState((state) => ({
579
650
  ...state,
@@ -808,17 +879,24 @@ var createForm = (schema, options) => {
808
879
  ...state,
809
880
  ...errorChannels(state, { serverErrors: errors })
810
881
  })),
811
- addErrors: (errors) => store.setState((state) => ({
812
- ...state,
813
- ...errorChannels(state, {
814
- serverErrors: Object.entries(errors).reduce(
815
- // Computed keys throughout, so a server field named
816
- // "__proto__" lands as an own property like everywhere else.
817
- (acc, [key, list]) => list === void 0 || list.length === 0 ? omitKey(acc, key) : { ...acc, [key]: list },
818
- state.serverErrors
819
- )
820
- })
821
- })),
882
+ addErrors: (errors) => store.setState((state) => {
883
+ const entries = Object.entries(errors);
884
+ const removed = new Set(
885
+ entries.filter(([, list]) => list === void 0 || list.length === 0).map(([key]) => key)
886
+ );
887
+ const added = Object.fromEntries(
888
+ entries.filter(([, list]) => list !== void 0 && list.length > 0)
889
+ );
890
+ return {
891
+ ...state,
892
+ ...errorChannels(state, {
893
+ serverErrors: {
894
+ ...omitKeys(state.serverErrors, removed),
895
+ ...added
896
+ }
897
+ })
898
+ };
899
+ }),
822
900
  clearErrors: (path) => store.setState((state) => {
823
901
  const scrub = (map) => path === void 0 ? emptyErrors : omitScope(map, [path]);
824
902
  return {
@@ -842,11 +920,12 @@ var createForm = (schema, options) => {
842
920
  if (next.values !== state.values) {
843
921
  clearArrayOpsUnder(store, "");
844
922
  }
923
+ const serverErrors = patch.serverErrors !== void 0 ? patch.serverErrors : next.values !== state.values ? releaseChangedSlices(state.serverErrors, state.values, next.values) : state.serverErrors;
845
924
  store.setState(() => ({
846
925
  ...next,
847
926
  ...errorChannels(state, {
848
927
  schemaErrors: next.schemaErrors,
849
- serverErrors: next.serverErrors
928
+ serverErrors
850
929
  })
851
930
  }));
852
931
  },
@@ -957,7 +1036,7 @@ var createForm = (schema, options) => {
957
1036
  handleSubmit: (onValid, onInvalid, opts) => async (event) => {
958
1037
  event?.preventDefault();
959
1038
  const result = await submit(onValid, onInvalid, opts);
960
- if (result.kind === "error" && opts?.onError === void 0 && process.env.NODE_ENV !== "production") {
1039
+ if (result.kind === "error" && opts?.onError === void 0 && isDevBuild()) {
961
1040
  console.warn(
962
1041
  "[formstand] submit handler threw and nothing observed it \u2014 the form shows no feedback. Pass { onError } to handleSubmit (or setError inside your handler):",
963
1042
  result.error
@@ -1068,7 +1147,7 @@ var persistForm = (form, options) => {
1068
1147
  const storage = options.storage ?? defaultStorage();
1069
1148
  const debounceMs = options.debounceMs ?? 300;
1070
1149
  const apply = options.apply ?? "adopt";
1071
- const applyDraft = (values) => apply === "restore" ? form.setValues(values) : form.adoptValues(values);
1150
+ const applyDraft = (values) => apply === "edit" || apply === "restore" ? form.setValues(values) : form.adoptValues(values);
1072
1151
  const referenceShape = () => {
1073
1152
  try {
1074
1153
  return JSON.parse(JSON.stringify(form.getState().initialValues));
@@ -1090,6 +1169,7 @@ var persistForm = (form, options) => {
1090
1169
  if (versioned && wrapper.__v !== options.version) return false;
1091
1170
  if (versioned && wrapper.values === void 0) return false;
1092
1171
  const values = versioned ? wrapper.values : parsed;
1172
+ if (values === null || values === void 0) return false;
1093
1173
  const reference = referenceShape();
1094
1174
  if (reference !== void 0 && conflictsWith(values, reference)) {
1095
1175
  return false;
@@ -1299,7 +1379,7 @@ var useMaskedInput = (field, options) => {
1299
1379
  field.setValue(parsed.value);
1300
1380
  return;
1301
1381
  case "empty": {
1302
- const empty = field.emptyValue;
1382
+ const empty = field.clearable || typeof field.initialValue !== "string" ? field.emptyValue : "";
1303
1383
  setEdit({ raw: text, pushed: empty });
1304
1384
  field.setValue(empty);
1305
1385
  return;
@@ -1339,9 +1419,14 @@ var useFormSteps = (form, steps) => {
1339
1419
  },
1340
1420
  [form]
1341
1421
  );
1422
+ const currentStep = useRef(step);
1423
+ useEffect(() => {
1424
+ currentStep.current = step;
1425
+ });
1342
1426
  const next = useCallback(async () => {
1343
1427
  const ok = await validateStep(step);
1344
1428
  if (!ok) return false;
1429
+ if (currentStep.current !== step) return false;
1345
1430
  const target = Math.min(step + 1, latest.current.length - 1);
1346
1431
  setStep(target);
1347
1432
  setVisited((prev) => /* @__PURE__ */ new Set([...prev, target]));
@@ -1363,6 +1448,7 @@ var useFormSteps = (form, steps) => {
1363
1448
  return await validateStep(from) ? walk(from + 1) : from;
1364
1449
  };
1365
1450
  const landed = await walk(step);
1451
+ if (currentStep.current !== step) return currentStep.current;
1366
1452
  setStep(landed);
1367
1453
  setVisited(
1368
1454
  (prev) => /* @__PURE__ */ new Set([
@@ -1408,7 +1494,7 @@ var useFormSteps = (form, steps) => {
1408
1494
  );
1409
1495
  };
1410
1496
  var warnUnobserved = (result, options, site) => {
1411
- if (result.kind === "error" && options?.onError === void 0 && process.env.NODE_ENV !== "production") {
1497
+ if (result.kind === "error" && options?.onError === void 0 && isDevBuild()) {
1412
1498
  console.warn(
1413
1499
  `[formstand] action handler threw and nothing observed it \u2014 the form shows no feedback. Pass { onError } to ${site} (or setError inside your handler):`,
1414
1500
  result.error
@@ -1477,10 +1563,12 @@ function useField(form, pathArg, options) {
1477
1563
  path: p,
1478
1564
  value,
1479
1565
  initialValue: initialValue2,
1480
- error: state.errors[p],
1481
- touched: state.touched[p] ?? false,
1566
+ // Own-key reads: a field named "constructor" or "toString" would
1567
+ // otherwise read Object.prototype's function as its error/flag.
1568
+ error: entryAt(state.errors, p),
1569
+ touched: flagAt(state.touched, p),
1482
1570
  dirty: isFieldDirty(value, initialValue2),
1483
- isValidating: state.isValidating[p] ?? false
1571
+ isValidating: flagAt(state.isValidating, p)
1484
1572
  };
1485
1573
  })
1486
1574
  );
@@ -1489,7 +1577,7 @@ function useField(form, pathArg, options) {
1489
1577
  const initialValue = slice.initialValue;
1490
1578
  const { emptyValue, clearable } = useMemo(() => {
1491
1579
  if (formSchema !== void 0) {
1492
- const sub = fieldSchemaAtPath(formSchema, path);
1580
+ const sub = fieldSchemaForIntrospection(formSchema, path);
1493
1581
  if (sub !== null) {
1494
1582
  return {
1495
1583
  emptyValue: emptyValueForSchema(sub),
@@ -1511,10 +1599,12 @@ function useField(form, pathArg, options) {
1511
1599
  if (debounceMs !== void 0 && debounceMs > 0) {
1512
1600
  const debouncer = debouncerRef.current ?? createDebouncer(debounceMs);
1513
1601
  debouncerRef.current = debouncer;
1514
- const scheduled = getAtPath(form.store.getState().values, path);
1602
+ const scheduledValues = form.store.getState().values;
1603
+ const scheduled = getAtPath(scheduledValues, path);
1515
1604
  debouncer.schedule(() => {
1516
1605
  const state = form.store.getState();
1517
- if (state.values === state.initialValues) return;
1606
+ const rebased = state.values !== scheduledValues && state.values === state.initialValues;
1607
+ if (rebased) return;
1518
1608
  if (!Object.is(getAtPath(state.values, path), scheduled)) return;
1519
1609
  void form.validateFieldAsync(path);
1520
1610
  });
@@ -1533,7 +1623,7 @@ function useField(form, pathArg, options) {
1533
1623
  state.mode,
1534
1624
  state.reValidateMode,
1535
1625
  state.submitCount > 0,
1536
- state.touched[path] ?? false
1626
+ flagAt(state.touched, path)
1537
1627
  )) {
1538
1628
  triggerValidate();
1539
1629
  }
@@ -1614,7 +1704,11 @@ var reconcileIds = (prev, nextItems) => {
1614
1704
  if (prev.items.length === nextItems.length && prev.items.every((item, i) => Object.is(item, nextItems[i]))) {
1615
1705
  return { ...prev, items: nextItems };
1616
1706
  }
1707
+ const keptAtIndex = nextItems.map(
1708
+ (item, i) => i < prev.items.length && Object.is(item, prev.items[i])
1709
+ );
1617
1710
  const buckets = prev.items.reduce((acc, item, i) => {
1711
+ if (keptAtIndex[i] === true) return acc;
1618
1712
  const bucket = acc.get(item);
1619
1713
  if (bucket === void 0) {
1620
1714
  acc.set(item, [i]);
@@ -1623,7 +1717,8 @@ var reconcileIds = (prev, nextItems) => {
1623
1717
  }
1624
1718
  return acc;
1625
1719
  }, /* @__PURE__ */ new Map());
1626
- const matchedIds = nextItems.map((item) => {
1720
+ const matchedIds = nextItems.map((item, i) => {
1721
+ if (keptAtIndex[i] === true) return prev.ids[i] ?? null;
1627
1722
  const prevIndex = buckets.get(item)?.shift();
1628
1723
  return prevIndex === void 0 ? null : prev.ids[prevIndex] ?? null;
1629
1724
  });
@@ -1717,7 +1812,9 @@ function useFieldArray(form, pathArg) {
1717
1812
  const value = getAtPath(state.values, path);
1718
1813
  return {
1719
1814
  items: Array.isArray(value) ? value : EMPTY_ITEMS,
1720
- error: state.errors[path]
1815
+ // Own-key read, like useField: an array named "constructor" must
1816
+ // not read Object as its error list.
1817
+ error: entryAt(state.errors, path)
1721
1818
  };
1722
1819
  })
1723
1820
  );
@@ -1731,7 +1828,7 @@ function useFieldArray(form, pathArg) {
1731
1828
  state.mode,
1732
1829
  state.reValidateMode,
1733
1830
  state.submitCount > 0,
1734
- state.touched[path] ?? false
1831
+ flagAt(state.touched, path)
1735
1832
  )) {
1736
1833
  form.validateField?.(path);
1737
1834
  }