formstand 0.15.2 → 0.17.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
@@ -297,7 +297,7 @@ var schemaHasPath = (schema, path) => {
297
297
  if (head === void 0) return true;
298
298
  if (s instanceof zod.z.ZodObject) {
299
299
  const shape = s.shape;
300
- const child = shape[String(head)];
300
+ const child = Object.hasOwn(shape, String(head)) ? shape[String(head)] : void 0;
301
301
  if (child !== void 0) return walk(child, rest, budget - 1);
302
302
  const catchall = s.def.catchall;
303
303
  return catchall !== void 0 && !(catchall instanceof zod.z.ZodNever);
@@ -317,7 +317,7 @@ var fieldSchemaAtPath = (schema, path) => parsePath(path).reduce((current, segme
317
317
  if (current === null || hasChecks(current)) return null;
318
318
  if (current instanceof zod.z.ZodObject && typeof segment === "string") {
319
319
  const shape = current.shape;
320
- return shape[segment] ?? null;
320
+ return Object.hasOwn(shape, segment) ? shape[segment] ?? null : null;
321
321
  }
322
322
  if (current instanceof zod.z.ZodArray && typeof segment === "number") {
323
323
  return current.element;
@@ -722,7 +722,7 @@ var createForm = (schema, options) => {
722
722
  };
723
723
  const applyArrayOp = (path, nextArray, mapper, indexInBounds) => {
724
724
  const current = getAtPath(store.getState().values, path);
725
- if (current !== void 0 && !Array.isArray(current)) {
725
+ if (current !== void 0 && current !== null && !Array.isArray(current)) {
726
726
  console.warn(
727
727
  `[formstand] array op on "${path}" but the value at that path is not an array (got ${typeof current}). Operation skipped.`
728
728
  );
@@ -831,6 +831,7 @@ var createForm = (schema, options) => {
831
831
  try {
832
832
  await onValid(result.data);
833
833
  } catch (error) {
834
+ submitOptions?.onError?.(error);
834
835
  return { kind: "error", error };
835
836
  }
836
837
  return { kind: "valid", data: result.data };
@@ -938,6 +939,17 @@ var createForm = (schema, options) => {
938
939
  ...state,
939
940
  ...errorChannels(state, { serverErrors: errors })
940
941
  })),
942
+ addErrors: (errors) => store.setState((state) => ({
943
+ ...state,
944
+ ...errorChannels(state, {
945
+ serverErrors: Object.entries(errors).reduce(
946
+ // Computed keys throughout, so a server field named
947
+ // "__proto__" lands as an own property like everywhere else.
948
+ (acc, [key, list]) => list === void 0 || list.length === 0 ? omitKey(acc, key) : { ...acc, [key]: list },
949
+ state.serverErrors
950
+ )
951
+ })
952
+ })),
941
953
  clearErrors: (path) => store.setState((state) => {
942
954
  const scrub = (map) => path === void 0 ? emptyErrors : omitScope(map, [path]);
943
955
  return {
@@ -1077,7 +1089,14 @@ var createForm = (schema, options) => {
1077
1089
  ),
1078
1090
  handleSubmit: (onValid, onInvalid, opts) => async (event) => {
1079
1091
  event?.preventDefault();
1080
- return submit(onValid, onInvalid, opts);
1092
+ const result = await submit(onValid, onInvalid, opts);
1093
+ if (result.kind === "error" && opts?.onError === void 0 && process.env.NODE_ENV !== "production") {
1094
+ console.warn(
1095
+ "[formstand] submit handler threw and nothing observed it \u2014 the form shows no feedback. Pass { onError } to handleSubmit (or setError inside your handler):",
1096
+ result.error
1097
+ );
1098
+ }
1099
+ return result;
1081
1100
  },
1082
1101
  diff: () => {
1083
1102
  const state = store.getState();
@@ -1185,6 +1204,9 @@ var persistForm = (form, options) => {
1185
1204
  const parsed = JSON.parse(raw);
1186
1205
  const versioned = options.version !== void 0 && typeof parsed === "object" && parsed !== null && "__v" in parsed;
1187
1206
  if (options.version !== void 0 && !versioned) return false;
1207
+ if (options.version === void 0 && typeof parsed === "object" && parsed !== null && Object.hasOwn(parsed, "__v") && Object.hasOwn(parsed, "values")) {
1208
+ return false;
1209
+ }
1188
1210
  const wrapper = parsed;
1189
1211
  if (versioned && wrapper.__v !== options.version) return false;
1190
1212
  if (versioned && wrapper.values === void 0) return false;
@@ -1248,6 +1270,33 @@ var persistForm = (form, options) => {
1248
1270
  }
1249
1271
  });
1250
1272
  };
1273
+ var usePersistForm = (form, options) => {
1274
+ const inner = react.useRef(null);
1275
+ const latestOptions = react.useRef(options);
1276
+ react.useEffect(() => {
1277
+ latestOptions.current = options;
1278
+ });
1279
+ const key = options.key;
1280
+ react.useEffect(() => {
1281
+ const handle = persistForm(form, latestOptions.current);
1282
+ inner.current = handle;
1283
+ return () => {
1284
+ inner.current = null;
1285
+ handle.dispose();
1286
+ };
1287
+ }, [form, key]);
1288
+ return react.useMemo(
1289
+ () => ({
1290
+ restore: () => inner.current?.restore() ?? false,
1291
+ clear: () => inner.current?.clear(),
1292
+ // dispose stops THIS component's watching early; the effect cleanup
1293
+ // still runs its own dispose harmlessly (persistForm's dispose is
1294
+ // idempotent by construction — it only cancels and unsubscribes).
1295
+ dispose: () => inner.current?.dispose()
1296
+ }),
1297
+ []
1298
+ );
1299
+ };
1251
1300
  var useForm = (schema, options) => {
1252
1301
  const formRef = react.useRef(null);
1253
1302
  if (formRef.current === null) {
@@ -1324,9 +1373,13 @@ function useField(form, pathArg, options) {
1324
1373
  if (debounceTimerRef.current !== null) {
1325
1374
  clearTimeout(debounceTimerRef.current);
1326
1375
  }
1376
+ const scheduled = getAtPath(form.store.getState().values, path);
1327
1377
  debounceTimerRef.current = setTimeout(() => {
1328
- void form.validateFieldAsync(path);
1329
1378
  debounceTimerRef.current = null;
1379
+ const state = form.store.getState();
1380
+ if (state.values === state.initialValues) return;
1381
+ if (!Object.is(getAtPath(state.values, path), scheduled)) return;
1382
+ void form.validateFieldAsync(path);
1330
1383
  }, debounceMs);
1331
1384
  return;
1332
1385
  }
@@ -1390,6 +1443,8 @@ function useField(form, pathArg, options) {
1390
1443
  initialValue: slice.initialValue,
1391
1444
  emptyValue,
1392
1445
  error: slice.error,
1446
+ // `?.[0]` covers both the undefined channel and an empty array.
1447
+ firstError: slice.error?.[0],
1393
1448
  touched: slice.touched,
1394
1449
  dirty: slice.dirty,
1395
1450
  isValidating: slice.isValidating,
@@ -1585,25 +1640,64 @@ function useFieldArray(form, pathArg) {
1585
1640
  })),
1586
1641
  [items, ids]
1587
1642
  );
1643
+ const setError = react.useCallback(
1644
+ // Same string-shorthand normalization as useField.setError, so custom
1645
+ // implementations typed for readonly string[] never receive a bare
1646
+ // string.
1647
+ (errors) => form.setError?.(path, typeof errors === "string" ? [errors] : errors),
1648
+ [form, path]
1649
+ );
1650
+ const clearError = react.useCallback(() => form.clearErrors?.(path), [form, path]);
1588
1651
  return react.useMemo(
1589
1652
  () => ({
1653
+ path,
1590
1654
  fields,
1591
1655
  items,
1592
1656
  length: items.length,
1593
1657
  error,
1658
+ firstError: error?.[0],
1659
+ setError,
1660
+ clearError,
1594
1661
  push,
1595
1662
  remove,
1596
1663
  insert,
1597
1664
  move,
1598
1665
  swap
1599
1666
  }),
1600
- [fields, items, error, push, remove, insert, move, swap]
1667
+ [path, fields, items, error, setError, clearError, push, remove, insert, move, swap]
1601
1668
  );
1602
1669
  }
1603
1670
  var useFormError = (form) => react$1.useStore(
1604
1671
  form.store,
1605
1672
  shallow.useShallow((state) => state.errors[""])
1606
1673
  );
1674
+ function useFormValues(form) {
1675
+ return react$1.useStore(form.store, (state) => state.values);
1676
+ }
1677
+
1678
+ // src/react/useFields.ts
1679
+ function useFields(form, paths) {
1680
+ const fields = paths.map(
1681
+ (path) => (
1682
+ // eslint-disable-next-line react-hooks/rules-of-hooks -- a fixed hook count by the stable-arity contract above; each element must be a REAL UseFieldReturn so it spreads into the prop builders
1683
+ useField(form, path)
1684
+ )
1685
+ );
1686
+ const merged = [...new Set(fields.flatMap((field) => field.error ?? []))];
1687
+ return {
1688
+ fields,
1689
+ error: merged.length > 0 ? merged : void 0,
1690
+ firstError: merged[0],
1691
+ touched: fields.some((field) => field.touched),
1692
+ dirty: fields.some((field) => field.dirty),
1693
+ isValidating: fields.some((field) => field.isValidating),
1694
+ onBlur: () => {
1695
+ fields.forEach((field) => {
1696
+ field.onBlur();
1697
+ });
1698
+ }
1699
+ };
1700
+ }
1607
1701
  function useIsDirty(form, path) {
1608
1702
  return react$1.useStore(
1609
1703
  form.store,
@@ -1624,6 +1718,19 @@ function useIsValid(form, path) {
1624
1718
  return true;
1625
1719
  });
1626
1720
  }
1721
+ function useIsValidating(form, path) {
1722
+ return react$1.useStore(form.store, (state) => {
1723
+ if (path === void 0 || path === "") {
1724
+ if (state.isValidatingForm) return true;
1725
+ }
1726
+ for (const k in state.isValidating) {
1727
+ if (state.isValidating[k] === true && (path === void 0 || path === "" || isPathOrChild(k, path))) {
1728
+ return true;
1729
+ }
1730
+ }
1731
+ return false;
1732
+ });
1733
+ }
1627
1734
  var useIsSubmitting = (form) => react$1.useStore(form.store, (state) => state.isSubmitting);
1628
1735
  var useSubmitCount = (form) => react$1.useStore(form.store, (state) => state.submitCount);
1629
1736
  function useFormSelector(form, selector) {
@@ -1649,7 +1756,13 @@ var createFormHooks = (form, name) => {
1649
1756
  const useBoundError = () => useFormError(structural);
1650
1757
  const useBoundIsDirty = (path) => useIsDirty(structural, path);
1651
1758
  const useBoundIsValid = (path) => useIsValid(structural, path);
1759
+ const useBoundIsValidating = (path) => useIsValidating(structural, path);
1652
1760
  const useBoundIsSubmitting = () => useIsSubmitting(structural);
1761
+ const useBoundValues = () => useFormValues(structural);
1762
+ const useBoundFields = (paths) => useFields(
1763
+ structural,
1764
+ paths
1765
+ );
1653
1766
  const useBoundSubmitCount = () => useSubmitCount(structural);
1654
1767
  const prefix = capitalize(name ?? "");
1655
1768
  return {
@@ -1661,13 +1774,13 @@ var createFormHooks = (form, name) => {
1661
1774
  [`use${prefix}Error`]: useBoundError,
1662
1775
  [`use${prefix}IsDirty`]: useBoundIsDirty,
1663
1776
  [`use${prefix}IsValid`]: useBoundIsValid,
1777
+ [`use${prefix}IsValidating`]: useBoundIsValidating,
1664
1778
  [`use${prefix}IsSubmitting`]: useBoundIsSubmitting,
1665
- [`use${prefix}SubmitCount`]: useBoundSubmitCount
1779
+ [`use${prefix}SubmitCount`]: useBoundSubmitCount,
1780
+ [`use${prefix}Values`]: useBoundValues,
1781
+ [`use${prefix}Fields`]: useBoundFields
1666
1782
  };
1667
1783
  };
1668
- function useFormValues(form) {
1669
- return react$1.useStore(form.store, (state) => state.values);
1670
- }
1671
1784
 
1672
1785
  // src/react/inputProps.ts
1673
1786
  var hasFieldError = (error) => error !== void 0 && error.length > 0;
@@ -1697,6 +1810,7 @@ var numberInputProps = (field) => ({
1697
1810
  "aria-invalid": ariaInvalid(field),
1698
1811
  onChange: (e) => {
1699
1812
  const parsed = parseNumberText(e.target.value);
1813
+ if (parsed.kind === "invalid") return;
1700
1814
  field.setValue(
1701
1815
  parsed.kind === "number" ? parsed.value : field.emptyValue
1702
1816
  );
@@ -2087,6 +2201,7 @@ exports.shouldValidateOn = shouldValidateOn;
2087
2201
  exports.textInputProps = textInputProps;
2088
2202
  exports.useField = useField;
2089
2203
  exports.useFieldArray = useFieldArray;
2204
+ exports.useFields = useFields;
2090
2205
  exports.useForm = useForm;
2091
2206
  exports.useFormError = useFormError;
2092
2207
  exports.useFormSelector = useFormSelector;
@@ -2095,7 +2210,9 @@ exports.useFormValues = useFormValues;
2095
2210
  exports.useIsDirty = useIsDirty;
2096
2211
  exports.useIsSubmitting = useIsSubmitting;
2097
2212
  exports.useIsValid = useIsValid;
2213
+ exports.useIsValidating = useIsValidating;
2098
2214
  exports.useNumberInput = useNumberInput;
2215
+ exports.usePersistForm = usePersistForm;
2099
2216
  exports.useSubmitCount = useSubmitCount;
2100
2217
  exports.useVariantField = useVariantField;
2101
2218
  exports.validateAsync = validateAsync;