formstand 0.17.0 → 0.19.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/CHANGELOG.md CHANGED
@@ -1,5 +1,86 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.19.0 — 2026-09-17
4
+
5
+ ### Added
6
+
7
+ - **The React 19 form-actions bridge.** `useFormAction(form, onValid,
8
+ options?)` returns an action for `<form action={...}>` that validates
9
+ first and hands the handler parsed `z.output` data (plus the raw
10
+ `FormData` last, for file pickers and uncontrolled co-inputs) under
11
+ the full submit lifecycle — isSubmitting/submitCount, errors written
12
+ and marked touched on failure, `onInvalid`/`onError` mirroring
13
+ `handleSubmit`, the same dev warning when a failure resolves
14
+ unobserved, and a reference-stable identity. `useFormActionState(form,
15
+ action, initialState, options?)` is `useActionState` with
16
+ schema-validated data: the handler's `(prevState, data, formData)`
17
+ shape lets a `"use server"` function slot straight in, and invalid,
18
+ skipped, or throwing submissions keep the previous state, because
19
+ field errors already live in the form store. Client-side integration
20
+ by design: no-JS progressive enhancement bypasses any client wrapper,
21
+ so that path validates server-side with the same zod schema.
22
+
23
+ ## 0.18.0 — 2026-09-17
24
+
25
+ ### Changed
26
+
27
+ - **`PersistHandle.restore()` is now `load()`** (breaking). The method's
28
+ DEFAULT application is `adoptValues` — a rebase that reads clean — and
29
+ the old name promised the setValues semantics it only has under
30
+ `apply: "restore"`. The name now describes what it does; the `apply`
31
+ mode names are unchanged. `usePersistForm`'s handle follows.
32
+ - **Cleared inputs write `emptyValue` whenever the schema accepts it.**
33
+ `textInputProps`/`selectProps` (and the bound `TextField`/`SelectField`)
34
+ used to write the empty value only for nullable fields, so clearing
35
+ `z.string().optional()` left `""` behind — a documented asymmetry that
36
+ kept the field dirty against an `undefined` initial and made an optional
37
+ enum select unclearable. Clearing now writes `undefined` for optional
38
+ and defaulted fields and `null` for nullable ones; a REQUIRED string
39
+ cleared to `""` still stays `""`, so the schema judges a visible value
40
+ instead of a hole it rejects. `SelectField`'s empty option is
41
+ accordingly selectable for every clearable field, not just nullable
42
+ ones.
43
+
44
+ ### Added
45
+
46
+ - **`field.clearable`** on `UseFieldReturn`: whether the schema accepts
47
+ the field's `emptyValue` (optional, nullable, or defaulted),
48
+ introspected from the zod schema with the same shallow walk as
49
+ `emptyValue` so the pair can never disagree. Schema-less forms fall
50
+ back to the null-initial heuristic. **`isClearableSchema(schema)`** is
51
+ the exported rule behind it, next to `emptyValueForSchema`.
52
+
53
+ ## formstand-cli 0.18.0 — 2026-09-17
54
+
55
+ ### Changed
56
+
57
+ - **Generated kit adapters clear through `field.clearable`.** The
58
+ emitted text and select bindings wrote the empty value only for
59
+ nullable fields, mirroring the library's old rule; they now write
60
+ `field.emptyValue` whenever `field.clearable` says the schema accepts
61
+ it, so generated optional fields clear to `undefined` instead of
62
+ holding `""`. Kit output therefore needs formstand 0.18; regenerate
63
+ to pick the rule up, or keep the old adapter — both compile.
64
+
65
+ ## formstand-cli 0.17.0 — 2026-09-17
66
+
67
+ ### Added
68
+
69
+ - **Containers inside array rows generate.** The remaining row-shape
70
+ TODO matrix is gone (formstand 0.17 types the paths): a tuple as the
71
+ row item or as a row-object field binds its positions inline at static
72
+ sub-indices (`points.${index}.0`, `segments.${index}.span.1`); a
73
+ discriminated union as a row-object FIELD binds on the holed template
74
+ (`invoices.${p0}.pay`), optional/nullable ones keeping the clearable
75
+ select; an array-of-arrays item extracts an inner Rows component; and
76
+ the same shapes under nested arrays work at any depth, union items
77
+ included (`teams.${p0}.methods.${index}`). Single-file extracts a
78
+ child component per site that needs hooks (rows render inside
79
+ rows.map); the module layout binds in the Row component, which already
80
+ is one. The one shape that still degrades to a TODO is a container
81
+ inside a union variant — binding it needs variant sub-paths the
82
+ library does not type.
83
+
3
84
  ## 0.17.0 — 2026-09-17
4
85
 
5
86
  ### Added
@@ -108,6 +108,7 @@ declare const validateSync: <TSchema extends z.ZodType>(schema: TSchema, values:
108
108
  declare const validateAsync: <TSchema extends z.ZodType>(schema: TSchema, values: z.input<TSchema>) => Promise<SettledValidationResult<z.output<TSchema>>>;
109
109
  declare const isAsyncRequiredError: (e: unknown) => boolean;
110
110
  declare const emptyValueForSchema: (schema: z.ZodType) => null | undefined;
111
+ declare const isClearableSchema: (schema: z.ZodType) => boolean;
111
112
 
112
113
  type ReadonlyStoreApi<T> = Pick<StoreApi<T>, "getState" | "getInitialState" | "subscribe">;
113
114
  type CreateFormOptions<TSchema extends z.ZodType, D extends PathDepth = DefaultPathDepth> = Readonly<{
@@ -197,4 +198,4 @@ type Form<TSchema extends z.ZodType, D extends PathDepth = DefaultPathDepth> = R
197
198
  }>;
198
199
  declare const createForm: <TSchema extends z.ZodType, D extends PathDepth = DefaultPathDepth>(schema: TSchema, options: CreateFormOptions<TSchema, D>) => Form<TSchema, D>;
199
200
 
200
- export { type BoolMap as B, type CreateFormOptions as C, type DefaultPathDepth as D, type ErrorMap as E, type Form as F, type InvalidSubmitHandler as I, type PathDepth as P, type ReadonlyStoreApi as R, type SettledFieldValidationResult as S, type ValidationMode as V, type FieldValidationResult as a, type FormState as b, type FieldPath as c, type FieldValue as d, type FieldSnapshot as e, type FieldsValidationResult as f, type ResetOptions as g, type SettledFieldsValidationResult as h, type SettledValidationResult as i, type SubmitHandler as j, type SubmitOptions as k, type SubmitResult as l, type ValidationResult as m, type ValidationTrigger as n, createForm as o, emptyValueForSchema as p, flattenIssues as q, isAsyncRequiredError as r, shouldValidateOn as s, validateSync as t, validateAsync as v };
201
+ export { type BoolMap as B, type CreateFormOptions as C, type DefaultPathDepth as D, type ErrorMap as E, type Form as F, type InvalidSubmitHandler as I, type PathDepth as P, type ReadonlyStoreApi as R, type SettledFieldValidationResult as S, type ValidationMode as V, type FieldValidationResult as a, type FormState as b, type FieldPath as c, type FieldValue as d, type FieldSnapshot as e, type FieldsValidationResult as f, type ResetOptions as g, type SettledFieldsValidationResult as h, type SettledValidationResult as i, type SubmitHandler as j, type SubmitOptions as k, type SubmitResult as l, type ValidationResult as m, type ValidationTrigger as n, createForm as o, emptyValueForSchema as p, flattenIssues as q, isAsyncRequiredError as r, isClearableSchema as s, shouldValidateOn as t, validateSync as u, validateAsync as v };
@@ -108,6 +108,7 @@ declare const validateSync: <TSchema extends z.ZodType>(schema: TSchema, values:
108
108
  declare const validateAsync: <TSchema extends z.ZodType>(schema: TSchema, values: z.input<TSchema>) => Promise<SettledValidationResult<z.output<TSchema>>>;
109
109
  declare const isAsyncRequiredError: (e: unknown) => boolean;
110
110
  declare const emptyValueForSchema: (schema: z.ZodType) => null | undefined;
111
+ declare const isClearableSchema: (schema: z.ZodType) => boolean;
111
112
 
112
113
  type ReadonlyStoreApi<T> = Pick<StoreApi<T>, "getState" | "getInitialState" | "subscribe">;
113
114
  type CreateFormOptions<TSchema extends z.ZodType, D extends PathDepth = DefaultPathDepth> = Readonly<{
@@ -197,4 +198,4 @@ type Form<TSchema extends z.ZodType, D extends PathDepth = DefaultPathDepth> = R
197
198
  }>;
198
199
  declare const createForm: <TSchema extends z.ZodType, D extends PathDepth = DefaultPathDepth>(schema: TSchema, options: CreateFormOptions<TSchema, D>) => Form<TSchema, D>;
199
200
 
200
- export { type BoolMap as B, type CreateFormOptions as C, type DefaultPathDepth as D, type ErrorMap as E, type Form as F, type InvalidSubmitHandler as I, type PathDepth as P, type ReadonlyStoreApi as R, type SettledFieldValidationResult as S, type ValidationMode as V, type FieldValidationResult as a, type FormState as b, type FieldPath as c, type FieldValue as d, type FieldSnapshot as e, type FieldsValidationResult as f, type ResetOptions as g, type SettledFieldsValidationResult as h, type SettledValidationResult as i, type SubmitHandler as j, type SubmitOptions as k, type SubmitResult as l, type ValidationResult as m, type ValidationTrigger as n, createForm as o, emptyValueForSchema as p, flattenIssues as q, isAsyncRequiredError as r, shouldValidateOn as s, validateSync as t, validateAsync as v };
201
+ export { type BoolMap as B, type CreateFormOptions as C, type DefaultPathDepth as D, type ErrorMap as E, type Form as F, type InvalidSubmitHandler as I, type PathDepth as P, type ReadonlyStoreApi as R, type SettledFieldValidationResult as S, type ValidationMode as V, type FieldValidationResult as a, type FormState as b, type FieldPath as c, type FieldValue as d, type FieldSnapshot as e, type FieldsValidationResult as f, type ResetOptions as g, type SettledFieldsValidationResult as h, type SettledValidationResult as i, type SubmitHandler as j, type SubmitOptions as k, type SubmitResult as l, type ValidationResult as m, type ValidationTrigger as n, createForm as o, emptyValueForSchema as p, flattenIssues as q, isAsyncRequiredError as r, isClearableSchema as s, shouldValidateOn as t, validateSync as u, validateAsync as v };
@@ -1,6 +1,6 @@
1
1
  import * as react from 'react';
2
2
  import { z } from 'zod';
3
- import { F as Form } from '../createForm-D0xLZH-Q.cjs';
3
+ import { F as Form } from '../createForm-B73p1TgL.cjs';
4
4
  import 'zustand/vanilla';
5
5
 
6
6
  type DevtoolsPosition = "bottom-right" | "bottom-left" | "top-right" | "top-left";
@@ -1,6 +1,6 @@
1
1
  import * as react from 'react';
2
2
  import { z } from 'zod';
3
- import { F as Form } from '../createForm-D0xLZH-Q.js';
3
+ import { F as Form } from '../createForm-B73p1TgL.js';
4
4
  import 'zustand/vanilla';
5
5
 
6
6
  type DevtoolsPosition = "bottom-right" | "bottom-left" | "top-right" | "top-left";
package/dist/index.cjs CHANGED
@@ -276,6 +276,7 @@ var emptyValueForSchema = (schema) => {
276
276
  };
277
277
  return walk(schema, false);
278
278
  };
279
+ var isClearableSchema = (schema) => schema instanceof zod.z.ZodOptional || schema instanceof zod.z.ZodNullable || schema instanceof zod.z.ZodDefault || schema instanceof zod.z.ZodPrefault;
279
280
  var unwrapForWalk = (s) => {
280
281
  if (s instanceof zod.z.ZodOptional || s instanceof zod.z.ZodNullable) {
281
282
  return s.unwrap();
@@ -1197,7 +1198,7 @@ var persistForm = (form, options) => {
1197
1198
  return void 0;
1198
1199
  }
1199
1200
  };
1200
- const restore = () => {
1201
+ const load = () => {
1201
1202
  try {
1202
1203
  const raw = storage === null ? null : storage.getItem(options.key);
1203
1204
  if (raw === null) return false;
@@ -1242,7 +1243,7 @@ var persistForm = (form, options) => {
1242
1243
  }
1243
1244
  };
1244
1245
  if (apply !== "manual") {
1245
- restore();
1246
+ load();
1246
1247
  }
1247
1248
  const unsubscribe = form.watchValues((values) => {
1248
1249
  cancelPending();
@@ -1256,7 +1257,7 @@ var persistForm = (form, options) => {
1256
1257
  }
1257
1258
  });
1258
1259
  return Object.freeze({
1259
- restore,
1260
+ load,
1260
1261
  clear: () => {
1261
1262
  cancelPending();
1262
1263
  try {
@@ -1287,7 +1288,7 @@ var usePersistForm = (form, options) => {
1287
1288
  }, [form, key]);
1288
1289
  return react.useMemo(
1289
1290
  () => ({
1290
- restore: () => inner.current?.restore() ?? false,
1291
+ load: () => inner.current?.load() ?? false,
1291
1292
  clear: () => inner.current?.clear(),
1292
1293
  // dispose stops THIS component's watching early; the effect cleanup
1293
1294
  // still runs its own dispose harmlessly (persistForm's dispose is
@@ -1316,6 +1317,51 @@ var useForm = (schema, options) => {
1316
1317
  }, [form, schema, options.initialValues]);
1317
1318
  return form;
1318
1319
  };
1320
+ var warnUnobserved = (result, options, site) => {
1321
+ if (result.kind === "error" && options?.onError === void 0 && process.env.NODE_ENV !== "production") {
1322
+ console.warn(
1323
+ `[formstand] action handler threw and nothing observed it \u2014 the form shows no feedback. Pass { onError } to ${site} (or setError inside your handler):`,
1324
+ result.error
1325
+ );
1326
+ }
1327
+ };
1328
+ var useFormAction = (form, onValid, options) => {
1329
+ const latest = react.useRef({ onValid, options });
1330
+ react.useEffect(() => {
1331
+ latest.current = { onValid, options };
1332
+ });
1333
+ return react.useCallback(
1334
+ async (formData) => {
1335
+ const { onValid: handler, options: opts } = latest.current;
1336
+ const result = await form.submit(
1337
+ (data) => handler(data, formData),
1338
+ opts?.onInvalid,
1339
+ opts?.onError === void 0 ? void 0 : { onError: opts.onError }
1340
+ );
1341
+ warnUnobserved(result, opts, "useFormAction");
1342
+ },
1343
+ [form]
1344
+ );
1345
+ };
1346
+ var useFormActionState = (form, action, initialState, options) => {
1347
+ const [state, formAction, isPending] = react.useActionState(
1348
+ async (prevState, formData) => {
1349
+ const prev = prevState;
1350
+ const nextRef = { current: prev };
1351
+ const result = await form.submit(
1352
+ async (data) => {
1353
+ nextRef.current = await action(prev, data, formData);
1354
+ },
1355
+ options?.onInvalid,
1356
+ options?.onError === void 0 ? void 0 : { onError: options.onError }
1357
+ );
1358
+ warnUnobserved(result, options, "useFormActionState");
1359
+ return nextRef.current;
1360
+ },
1361
+ initialState
1362
+ );
1363
+ return [state, formAction, isPending];
1364
+ };
1319
1365
  var createFormContext = () => {
1320
1366
  const Context = react.createContext(null);
1321
1367
  const Provider = ({ form, children }) => /* @__PURE__ */ jsxRuntime.jsx(Context.Provider, { value: form, children });
@@ -1351,12 +1397,17 @@ function useField(form, pathArg, options) {
1351
1397
  const path = slice.path;
1352
1398
  const formSchema = form.schema;
1353
1399
  const initialValue = slice.initialValue;
1354
- const emptyValue = react.useMemo(() => {
1400
+ const { emptyValue, clearable } = react.useMemo(() => {
1355
1401
  if (formSchema !== void 0) {
1356
1402
  const sub = fieldSchemaAtPath(formSchema, path);
1357
- if (sub !== null) return emptyValueForSchema(sub);
1403
+ if (sub !== null) {
1404
+ return {
1405
+ emptyValue: emptyValueForSchema(sub),
1406
+ clearable: isClearableSchema(sub)
1407
+ };
1408
+ }
1358
1409
  }
1359
- return initialValue === null ? null : void 0;
1410
+ return initialValue === null ? { emptyValue: null, clearable: true } : { emptyValue: void 0, clearable: false };
1360
1411
  }, [formSchema, path, initialValue]);
1361
1412
  const debounceMs = options?.debounceMs;
1362
1413
  const debounceTimerRef = react.useRef(null);
@@ -1442,6 +1493,7 @@ function useField(form, pathArg, options) {
1442
1493
  value: slice.value,
1443
1494
  initialValue: slice.initialValue,
1444
1495
  emptyValue,
1496
+ clearable,
1445
1497
  error: slice.error,
1446
1498
  // `?.[0]` covers both the undefined channel and an empty array.
1447
1499
  firstError: slice.error?.[0],
@@ -1456,7 +1508,7 @@ function useField(form, pathArg, options) {
1456
1508
  validateAsync: validateAsync2,
1457
1509
  onBlur
1458
1510
  }),
1459
- [slice, emptyValue, setValue, setTouched, setError, clearError, validate, validateAsync2, onBlur]
1511
+ [slice, emptyValue, clearable, setValue, setTouched, setError, clearError, validate, validateAsync2, onBlur]
1460
1512
  );
1461
1513
  }
1462
1514
 
@@ -1797,9 +1849,7 @@ var textInputProps = (field) => ({
1797
1849
  "aria-invalid": ariaInvalid(field),
1798
1850
  onChange: (e) => {
1799
1851
  const text = e.target.value;
1800
- field.setValue(
1801
- text === "" && field.emptyValue === null ? null : text
1802
- );
1852
+ field.setValue(text === "" && field.clearable ? field.emptyValue : text);
1803
1853
  },
1804
1854
  onBlur: field.onBlur
1805
1855
  });
@@ -1873,9 +1923,7 @@ var selectProps = (field) => ({
1873
1923
  "aria-invalid": ariaInvalid(field),
1874
1924
  onChange: (e) => {
1875
1925
  const v = e.target.value;
1876
- field.setValue(
1877
- v === "" && field.emptyValue === null ? null : v
1878
- );
1926
+ field.setValue(v === "" && field.clearable ? field.emptyValue : v);
1879
1927
  },
1880
1928
  onBlur: field.onBlur
1881
1929
  });
@@ -2082,7 +2130,7 @@ var SelectField = ({
2082
2130
  const field = useField(form, path);
2083
2131
  const a11y = useFieldA11y(field.error);
2084
2132
  const select = selectProps(field);
2085
- const clearable = field.emptyValue === null;
2133
+ const clearable = field.clearable;
2086
2134
  const hasExplicitEmptyOption = options.some((opt) => opt.value === "");
2087
2135
  const showEmptyOption = !hasExplicitEmptyOption && (clearable || select.value === "" || placeholder !== void 0);
2088
2136
  return /* @__PURE__ */ jsxRuntime.jsx(
@@ -2189,6 +2237,7 @@ exports.focusFirstError = focusFirstError;
2189
2237
  exports.getAtPath = getAtPath;
2190
2238
  exports.hasFieldError = hasFieldError;
2191
2239
  exports.isAsyncRequiredError = isAsyncRequiredError;
2240
+ exports.isClearableSchema = isClearableSchema;
2192
2241
  exports.numberInputProps = numberInputProps;
2193
2242
  exports.numberToInputText = numberToInputText;
2194
2243
  exports.parseDateText = parseDateText;
@@ -2203,6 +2252,8 @@ exports.useField = useField;
2203
2252
  exports.useFieldArray = useFieldArray;
2204
2253
  exports.useFields = useFields;
2205
2254
  exports.useForm = useForm;
2255
+ exports.useFormAction = useFormAction;
2256
+ exports.useFormActionState = useFormActionState;
2206
2257
  exports.useFormError = useFormError;
2207
2258
  exports.useFormSelector = useFormSelector;
2208
2259
  exports.useFormSelectorShallow = useFormSelectorShallow;