formstand 0.4.0 → 0.5.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,141 @@
1
1
  # Changelog
2
2
 
3
+ ## formstand-cli Unreleased
4
+
5
+ - Generated array hooks drop the explicit item type
6
+ (`useFieldArray(form, "items")`): formstand ≥ 0.5 infers it from the
7
+ schema through the path — and rejects the old explicit spelling on typed
8
+ forms. On formstand 0.4 the generated code still compiles, with untyped
9
+ items.
10
+ - Generated shadcn output defines one `ariaInvalid` helper (used by the
11
+ inlined adapters and the select trigger) instead of repeating the ternary
12
+ four times, and the generated `FieldError` computes the message once —
13
+ cosmetic; runtime behavior unchanged. Internally the MUI and shadcn
14
+ backends now share their emitted snippets (error helper, `BoundFieldProps`,
15
+ the leaf switch), so the generators can't drift.
16
+
17
+ ## 0.5.0 — 2026-07-09
18
+
19
+ ### Breaking
20
+
21
+ - `useFieldArray` infers the item type from a `Form<TSchema>` and a typed
22
+ path — `useFieldArray(form, "users")` (and template paths like
23
+ `` `albums.${index}.tracks` ``) needs no type argument, and `push`/`insert`
24
+ are typed against the schema's item. Consequently the old explicit
25
+ `useFieldArray<TItem>(form, path)` spelling is a **compile error on typed
26
+ forms** (drop the generic); it remains the way to bind schema-less
27
+ `FieldFormApi` forms, where there is nothing to infer from. A non-array
28
+ path types the items as `never`; a typo'd path is rejected against the
29
+ full `FieldPath` union, like `useField`. Path selectors return
30
+ `UseFieldArrayReturn<unknown>` (dynamic paths carry no type), also like
31
+ `useField`.
32
+
33
+ ### Fixed (site only)
34
+
35
+ - The deployed playground bundled two copies of React (the formstand alias
36
+ reaches outside the examples package, so its imports resolved the repo
37
+ root's copy) — every tab crashed at startup with a null hooks
38
+ dispatcher. The examples build now dedupes react/zustand/zod and CI
39
+ asserts the bundle holds exactly one React.
40
+
41
+ ### Docs & examples (no package changes)
42
+
43
+ - The playground demos bind shadcn's `Input`/`Textarea` with the library's
44
+ own exported `textInputProps`/`numberInputProps` — the shadcn adapter now
45
+ covers only the Radix dialect, and the "field has an error" predicate
46
+ lives once in `examples/src/fieldErrors.ts` (shared by both adapters).
47
+ - The copy-in shadcn kit is trimmed to what the demos render (Badge/Button
48
+ variants, dead peer-disabled label classes) and every component exports a
49
+ `Readonly` props type.
50
+
51
+ ## formstand-cli 0.2.1 — 2026-07-09
52
+
53
+ ### Fixed
54
+
55
+ - Leaf-free schemas (no scalar fields anywhere, e.g. an object of empty
56
+ objects) no longer generate non-compiling output in the `mui` and
57
+ `shadcn` backends — the emitted `BoundFieldProps` type referenced
58
+ `FieldFormApi` without importing it. All three backends now have a
59
+ leaf-free typecheck test.
60
+ - The shadcn backend's generated output is additionally typechecked in CI
61
+ against the repo's real Radix-based components (not just the structural
62
+ stub), so a shadcn/Radix prop-contract change fails our suite instead of
63
+ the consumer's build.
64
+
65
+ ### Docs & examples (no library package changes)
66
+
67
+ - Playground: the page chrome stylesheet is unlayered again — cascade
68
+ layers can't be transpiled, so pre-15.4 WebKit (the es2019 target's
69
+ audience) was losing every tab's styling; its selectors are class-scoped,
70
+ so the shadcn utilities are unaffected. The layer-order statement now
71
+ pins Tailwind's canonical `components` slot below `utilities`.
72
+ - shadcn kit: sliders take an `aria-label` routed to the Radix Thumb (the
73
+ Root is a `<span>`, so `htmlFor`/`id` never named it); radio-group items
74
+ show error styling via `group-aria-invalid` (Radix doesn't propagate the
75
+ Root's `aria-invalid` to items); the Team demo's rows are memoized with
76
+ stable handlers so keystrokes in one row stop re-rendering the rest.
77
+ - Publish workflow: `--provenance` is explicit again (fail-closed — the
78
+ automatic path silently skips attestation when conditions aren't met) and
79
+ the Node pin is `>=24.8`, the first line whose bundled npm meets trusted
80
+ publishing's 11.5.1 floor. CI asserts the root and examples lockfiles
81
+ agree on the deduped UI packages, so the smoke test can't silently
82
+ certify a different build than the deployed playground.
83
+
84
+ ## formstand-cli 0.2.0 — 2026-07-08
85
+
86
+ ### Added
87
+
88
+ - `--ui shadcn`: generates forms against [shadcn/ui](https://ui.shadcn.com/)
89
+ conventions — components imported from the app's `@/components/ui/*` alias
90
+ (what `npx shadcn add` scaffolds), an inlined adapter speaking the Radix
91
+ dialect (`onCheckedChange`/`onValueChange`, dropdown-close as the blur
92
+ trigger), and `aria-invalid` error styling with a message line. Generated
93
+ output is typechecked in CI against the library source and a structural
94
+ stub of the shadcn components, like the other backends.
95
+ - `emitShadcnForm` joins the programmatic API.
96
+
97
+ ### Docs & examples (no library package changes)
98
+
99
+ - Four shadcn/ui playground demos (Signup, Checkout, Settings, Team) plus
100
+ the formstand→shadcn adapter pattern they showcase —
101
+ `examples/src/shadcn/shadcnAdapter.ts` bridges `useField` to both native
102
+ inputs and the Radix-based widgets (Checkbox, Switch, Select, Slider,
103
+ RadioGroup).
104
+ - The playground carries its own copy-in shadcn component kit under a
105
+ scoped Tailwind 4 setup (no preflight; the plain-CSS and MUI demos are
106
+ untouched).
107
+
108
+ ## 0.4.1 — 2026-07-08
109
+
110
+ ### Fixed
111
+
112
+ - `submit`'s stale-write guard now uses pass ownership (cleared by
113
+ `reset`/`adoptValues`) in addition to the values-reference check — a bare
114
+ `reset()` on a pristine form during an in-flight submit no longer gets
115
+ stale errors and touched marks committed (reference equality couldn't see
116
+ it: reset restores the same `initialValues` reference).
117
+ - `restore()` no longer resurrects in-flight validation flags captured in a
118
+ snapshot (`isValidating`/`isValidatingForm` are transient, owned by live
119
+ passes — a restored copy could stick forever).
120
+ - `focusFirstError`/`focusField` verify focus against the element's own root
121
+ node, fixing a regression that walked past every candidate inside a shadow
122
+ root and reported failure after moving focus to the wrong control.
123
+ - `focusField("")` focuses the form's first focusable control (whole-form
124
+ scope, like its imperative siblings), with the same multi-form
125
+ refuse-to-guess rule as `focusFirstError`.
126
+ - `field.setError("string")` is normalized to an array before reaching
127
+ `FieldFormApi.setError`, shielding pre-0.4 custom implementations typed
128
+ for `readonly string[]`.
129
+
130
+ ### Internal
131
+
132
+ - Validation pass ownership uses unique `Symbol()` tokens — collisions after
133
+ `reset`/`adoptValues` are impossible by construction rather than guarded
134
+ by a never-reset counter invariant.
135
+ - Publish workflow fails fast when the pushed tag doesn't match
136
+ `package.json`'s version; the CLI release checklist pushes the release
137
+ commit, not just the tag.
138
+
3
139
  ## 0.4.0 — 2026-07-08
4
140
 
5
141
  ### Breaking
@@ -22,10 +158,13 @@
22
158
  - `adoptValues` now clears the in-flight validation flags
23
159
  (`isValidating` / `isValidatingForm`) along with the errors it already
24
160
  cleared — the rebase disowns in-flight passes.
25
- - `submit` skips its error/touched state writes when `values` changed while
26
- validation was in flight (a `reset` during an in-flight submit no longer
27
- gets stale errors committed over the fresh form); `onValid`/`onInvalid`
28
- still run and the result still reports the outcome.
161
+ - `submit` skips its error/touched state writes when the form was rebased
162
+ while validation was in flight: when `values` changed, when `reset` /
163
+ `adoptValues` ran (including a bare `reset()` on a pristine form, where the
164
+ values reference doesn't change), or when a concurrent
165
+ `submit({ force: true })` re-claimed ownership (the LAST submit's writes
166
+ land). `onValid`/`onInvalid` still run and the result still reports the
167
+ outcome.
29
168
 
30
169
  ### Added
31
170
 
@@ -35,11 +174,16 @@
35
174
  behind `useField().emptyValue`, alongside its adapter siblings
36
175
  `numberToInputText` / `parseNumberText`.
37
176
  - `field.setError` (from `useField`) accepts a single string, matching
38
- `form.setError`.
39
- - Dev-mode warning (once per path) when `validateField` /
40
- `validateFieldAsync` target a path the schema provably cannot contain —
41
- protects the docs-sanctioned dynamic-path casts from silent always-valid
42
- results.
177
+ `form.setError`. The hook normalizes the shorthand to a one-element array
178
+ before forwarding, so custom `FieldFormApi` implementations typed for
179
+ `readonly string[]` never receive a bare string.
180
+ - `focusField("")` focuses the first focusable control in scope (the
181
+ whole-form `""` semantics of the imperative surface); under the default
182
+ `document` scope it refuses to guess between multiple `<form>`s, like
183
+ `focusFirstError`'s root-`""` fallback.
184
+ - `validateField` / `validateFieldAsync` targeting a path the schema
185
+ provably cannot contain now warn (once per path per form) — protects the
186
+ docs-sanctioned dynamic-path casts from silent always-valid results.
43
187
  - Docs: a migrating-from-react-hook-form guide with the full API mapping
44
188
  table.
45
189
 
@@ -48,9 +192,16 @@
48
192
  - Array ops no longer strand in-flight `isValidating` flags: flags under the
49
193
  path are dropped rather than re-keyed (the completing pass clears the
50
194
  original key, so a re-keyed flag could never be cleared).
51
- - The validation sequence counter is globally monotonic and never resets, so
52
- a superseded pre-reset validation pass can no longer collide with a
53
- post-reset one and clobber its state.
195
+ - In-flight validation passes are owned via unique symbol tokens (unique by
196
+ construction), so a superseded pre-reset validation pass can never collide
197
+ with a post-reset one and clobber its state.
198
+ - `restore(snapshot)` clears the transient in-flight flags (`isValidating` /
199
+ `isValidatingForm`) instead of restoring them — in-flight state is owned
200
+ by live passes, never by snapshots, so a restored flag would stick forever.
201
+ - `focusFirstError` / `focusField` verify focus against the element's own
202
+ root (`getRootNode().activeElement`), so controls inside a shadow root are
203
+ no longer reported as unfocused (`document.activeElement` retargets to the
204
+ shadow host).
54
205
  - `validateFieldAsync("")` delegates to the whole-form pass, so its pending
55
206
  state lives in `isValidatingForm` instead of `isValidating[""]`.
56
207
  - `focusFirstError` / `focusField` report success only when a control
package/dist/index.cjs CHANGED
@@ -274,6 +274,7 @@ var dirtyPathsOf = (values, initial, prefix) => {
274
274
  return [prefix];
275
275
  };
276
276
  var FORM_SEQ_KEY = "__form__";
277
+ var SUBMIT_SEQ_KEY = "__submit__";
277
278
  var stringArraysEqual = (a, b) => a === b || a.length === b.length && a.every((v, i) => v === b[i]);
278
279
  var reuseErrorRefs = (next, prev) => {
279
280
  if (next === prev) return prev;
@@ -466,19 +467,18 @@ var createForm = (schema, options) => {
466
467
  };
467
468
  };
468
469
  const sequences = /* @__PURE__ */ new Map();
469
- const seqRef = { current: 0 };
470
- const nextSeq = (key) => {
471
- seqRef.current += 1;
472
- sequences.set(key, seqRef.current);
473
- return seqRef.current;
470
+ const claimPass = (key) => {
471
+ const pass = Symbol(key);
472
+ sequences.set(key, pass);
473
+ return pass;
474
474
  };
475
475
  const validateAsyncOnForm = async () => {
476
- const seq = nextSeq(FORM_SEQ_KEY);
476
+ const pass = claimPass(FORM_SEQ_KEY);
477
477
  const valuesAtStart = store.getState().values;
478
478
  store.setState((state) => ({ ...state, isValidatingForm: true }));
479
479
  const result = await validateAsync(schema, valuesAtStart);
480
480
  const live = store.getState();
481
- const stillOwns = sequences.get(FORM_SEQ_KEY) === seq;
481
+ const stillOwns = sequences.get(FORM_SEQ_KEY) === pass;
482
482
  if (!stillOwns || live.values !== valuesAtStart) {
483
483
  if (stillOwns) {
484
484
  store.setState((state) => ({ ...state, isValidatingForm: false }));
@@ -501,7 +501,7 @@ var createForm = (schema, options) => {
501
501
  const result2 = await validateAsyncOnForm();
502
502
  return result2.kind === "valid";
503
503
  }
504
- const seqs = new Map(paths.map((p) => [p, nextSeq(p)]));
504
+ const passes = new Map(paths.map((p) => [p, claimPass(p)]));
505
505
  const valuesAtStart = store.getState().values;
506
506
  store.setState((state) => ({
507
507
  ...state,
@@ -516,7 +516,9 @@ var createForm = (schema, options) => {
516
516
  (k) => !inScopeOfAny(k, paths)
517
517
  );
518
518
  const live = store.getState();
519
- const stillCurrent = paths.filter((p) => sequences.get(p) === seqs.get(p));
519
+ const stillCurrent = paths.filter(
520
+ (p) => sequences.get(p) === passes.get(p)
521
+ );
520
522
  if (live.values !== valuesAtStart) {
521
523
  if (stillCurrent.length > 0) {
522
524
  store.setState((state) => ({
@@ -557,7 +559,7 @@ var createForm = (schema, options) => {
557
559
  result.kind === "invalid" ? result.errors : emptyErrors
558
560
  );
559
561
  }
560
- const seq = nextSeq(path);
562
+ const pass = claimPass(path);
561
563
  const valuesAtStart = store.getState().values;
562
564
  store.setState((state) => ({
563
565
  ...state,
@@ -565,7 +567,7 @@ var createForm = (schema, options) => {
565
567
  }));
566
568
  const scoped = await scopedFieldErrorsAsync(path, valuesAtStart);
567
569
  const live = store.getState();
568
- const stillOwns = sequences.get(path) === seq;
570
+ const stillOwns = sequences.get(path) === pass;
569
571
  if (!stillOwns || live.values !== valuesAtStart) {
570
572
  if (stillOwns) {
571
573
  store.setState((state) => ({
@@ -644,11 +646,12 @@ var createForm = (schema, options) => {
644
646
  submitCount: state.submitCount + 1
645
647
  }));
646
648
  try {
649
+ const pass = claimPass(SUBMIT_SEQ_KEY);
647
650
  const valuesAtStart = store.getState().values;
648
651
  const result = await validateAsync(schema, valuesAtStart);
649
- const staleValues = store.getState().values !== valuesAtStart;
652
+ const stale = sequences.get(SUBMIT_SEQ_KEY) !== pass || store.getState().values !== valuesAtStart;
650
653
  if (result.kind === "invalid") {
651
- if (!staleValues) {
654
+ if (!stale) {
652
655
  store.setState((state) => {
653
656
  const committed = commitFullPass(state, result.errors);
654
657
  return {
@@ -666,11 +669,11 @@ var createForm = (schema, options) => {
666
669
  };
667
670
  });
668
671
  }
669
- const merged = staleValues ? result.errors : store.getState().errors;
672
+ const merged = stale ? result.errors : store.getState().errors;
670
673
  onInvalid?.(merged);
671
674
  return { kind: "invalid", errors: merged };
672
675
  }
673
- if (!staleValues) {
676
+ if (!stale) {
674
677
  store.setState((state) => ({
675
678
  ...state,
676
679
  ...commitFullPass(state, emptyErrors)
@@ -923,7 +926,13 @@ var createForm = (schema, options) => {
923
926
  errors: reuseErrorRefs(
924
927
  mergeErrorChannels(schemaErrors, serverErrors),
925
928
  snap.errors ?? emptyErrors
926
- )
929
+ ),
930
+ // In-flight state is owned by live passes, never by snapshots: a
931
+ // snapshotted flag has no pass left to clear it (the pass that set
932
+ // it cleared its ORIGINAL entry when it settled), so restoring one
933
+ // would stick it forever.
934
+ isValidating: emptyBools,
935
+ isValidatingForm: false
927
936
  };
928
937
  })
929
938
  });
@@ -1079,7 +1088,10 @@ function useField(form, pathArg, options) {
1079
1088
  [form, path]
1080
1089
  );
1081
1090
  const setError = react.useCallback(
1082
- (errors) => form.setError(path, errors),
1091
+ // Normalize the string shorthand HERE, before forwarding: custom
1092
+ // FieldFormApi implementations written against the pre-0.4 surface are
1093
+ // typed for readonly string[] and must never receive a bare string.
1094
+ (errors) => form.setError(path, typeof errors === "string" ? [errors] : errors),
1083
1095
  [form, path]
1084
1096
  );
1085
1097
  const clearError = react.useCallback(
@@ -1348,9 +1360,14 @@ var focusableControls = (scope) => [
1348
1360
  ].filter(
1349
1361
  (el) => !el.matches(':disabled, [type="hidden"]') && el.closest("dialog:not([open])") === null
1350
1362
  );
1363
+ var holdsFocus = (el) => {
1364
+ const rootNode = el.getRootNode();
1365
+ const active = rootNode instanceof Document || rootNode instanceof ShadowRoot ? rootNode.activeElement : document.activeElement;
1366
+ return active === el;
1367
+ };
1351
1368
  var focusFirstOf = (candidates) => candidates.some((el) => {
1352
1369
  el.focus();
1353
- return document.activeElement === el;
1370
+ return holdsFocus(el);
1354
1371
  });
1355
1372
  var nameMatchesAny = (el, paths) => {
1356
1373
  const name = el.getAttribute("name");
@@ -1370,11 +1387,10 @@ var focusFirstError = (errors, root) => {
1370
1387
  const candidates = fieldMatches.length > 0 ? fieldMatches : rootFallbackApplies ? controls : [];
1371
1388
  return focusFirstOf(candidates);
1372
1389
  };
1373
- var focusField = (path, root) => focusFirstOf(
1374
- focusableControls(root ?? document).filter(
1375
- (el) => nameMatchesAny(el, [path])
1376
- )
1377
- );
1390
+ var focusField = (path, root) => {
1391
+ const controls = focusableControls(root ?? document);
1392
+ return path === "" ? (root !== void 0 || document.querySelectorAll("form").length <= 1) && focusFirstOf(controls) : focusFirstOf(controls.filter((el) => nameMatchesAny(el, [path])));
1393
+ };
1378
1394
  var EMPTY_ID_STATE = { items: [], ids: [], counter: 0 };
1379
1395
  var EMPTY_ITEMS = [];
1380
1396
  var reconcileIds = (prev, nextItems) => {
@@ -1413,7 +1429,7 @@ var reconcileIds = (prev, nextItems) => {
1413
1429
  counter: prev.counter + Math.max(0, unmatched - leftover.length)
1414
1430
  };
1415
1431
  };
1416
- var useFieldArray = (form, pathArg) => {
1432
+ function useFieldArray(form, pathArg) {
1417
1433
  const path = react$1.useStore(
1418
1434
  form.store,
1419
1435
  (state) => typeof pathArg === "function" ? pathArg(state) : pathArg
@@ -1478,7 +1494,7 @@ var useFieldArray = (form, pathArg) => {
1478
1494
  }),
1479
1495
  [fields, items, error, push, remove, insert, move, swap]
1480
1496
  );
1481
- };
1497
+ }
1482
1498
 
1483
1499
  exports.CheckboxField = CheckboxField;
1484
1500
  exports.NumberField = NumberField;