formstand 0.4.0 → 0.4.1

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,36 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.4.1 — 2026-07-08
4
+
5
+ ### Fixed
6
+
7
+ - `submit`'s stale-write guard now uses pass ownership (cleared by
8
+ `reset`/`adoptValues`) in addition to the values-reference check — a bare
9
+ `reset()` on a pristine form during an in-flight submit no longer gets
10
+ stale errors and touched marks committed (reference equality couldn't see
11
+ it: reset restores the same `initialValues` reference).
12
+ - `restore()` no longer resurrects in-flight validation flags captured in a
13
+ snapshot (`isValidating`/`isValidatingForm` are transient, owned by live
14
+ passes — a restored copy could stick forever).
15
+ - `focusFirstError`/`focusField` verify focus against the element's own root
16
+ node, fixing a regression that walked past every candidate inside a shadow
17
+ root and reported failure after moving focus to the wrong control.
18
+ - `focusField("")` focuses the form's first focusable control (whole-form
19
+ scope, like its imperative siblings), with the same multi-form
20
+ refuse-to-guess rule as `focusFirstError`.
21
+ - `field.setError("string")` is normalized to an array before reaching
22
+ `FieldFormApi.setError`, shielding pre-0.4 custom implementations typed
23
+ for `readonly string[]`.
24
+
25
+ ### Internal
26
+
27
+ - Validation pass ownership uses unique `Symbol()` tokens — collisions after
28
+ `reset`/`adoptValues` are impossible by construction rather than guarded
29
+ by a never-reset counter invariant.
30
+ - Publish workflow fails fast when the pushed tag doesn't match
31
+ `package.json`'s version; the CLI release checklist pushes the release
32
+ commit, not just the tag.
33
+
3
34
  ## 0.4.0 — 2026-07-08
4
35
 
5
36
  ### Breaking
@@ -22,10 +53,13 @@
22
53
  - `adoptValues` now clears the in-flight validation flags
23
54
  (`isValidating` / `isValidatingForm`) along with the errors it already
24
55
  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.
56
+ - `submit` skips its error/touched state writes when the form was rebased
57
+ while validation was in flight: when `values` changed, when `reset` /
58
+ `adoptValues` ran (including a bare `reset()` on a pristine form, where the
59
+ values reference doesn't change), or when a concurrent
60
+ `submit({ force: true })` re-claimed ownership (the LAST submit's writes
61
+ land). `onValid`/`onInvalid` still run and the result still reports the
62
+ outcome.
29
63
 
30
64
  ### Added
31
65
 
@@ -35,11 +69,16 @@
35
69
  behind `useField().emptyValue`, alongside its adapter siblings
36
70
  `numberToInputText` / `parseNumberText`.
37
71
  - `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.
72
+ `form.setError`. The hook normalizes the shorthand to a one-element array
73
+ before forwarding, so custom `FieldFormApi` implementations typed for
74
+ `readonly string[]` never receive a bare string.
75
+ - `focusField("")` focuses the first focusable control in scope (the
76
+ whole-form `""` semantics of the imperative surface); under the default
77
+ `document` scope it refuses to guess between multiple `<form>`s, like
78
+ `focusFirstError`'s root-`""` fallback.
79
+ - `validateField` / `validateFieldAsync` targeting a path the schema
80
+ provably cannot contain now warn (once per path per form) — protects the
81
+ docs-sanctioned dynamic-path casts from silent always-valid results.
43
82
  - Docs: a migrating-from-react-hook-form guide with the full API mapping
44
83
  table.
45
84
 
@@ -48,9 +87,16 @@
48
87
  - Array ops no longer strand in-flight `isValidating` flags: flags under the
49
88
  path are dropped rather than re-keyed (the completing pass clears the
50
89
  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.
90
+ - In-flight validation passes are owned via unique symbol tokens (unique by
91
+ construction), so a superseded pre-reset validation pass can never collide
92
+ with a post-reset one and clobber its state.
93
+ - `restore(snapshot)` clears the transient in-flight flags (`isValidating` /
94
+ `isValidatingForm`) instead of restoring them — in-flight state is owned
95
+ by live passes, never by snapshots, so a restored flag would stick forever.
96
+ - `focusFirstError` / `focusField` verify focus against the element's own
97
+ root (`getRootNode().activeElement`), so controls inside a shadow root are
98
+ no longer reported as unfocused (`document.activeElement` retargets to the
99
+ shadow host).
54
100
  - `validateFieldAsync("")` delegates to the whole-form pass, so its pending
55
101
  state lives in `isValidatingForm` instead of `isValidating[""]`.
56
102
  - `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) => {