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 +58 -12
- package/dist/index.cjs +39 -23
- package/dist/index.cjs.map +1 -1
- package/dist/index.js +39 -23
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
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
|
|
26
|
-
validation was in flight
|
|
27
|
-
|
|
28
|
-
|
|
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
|
-
|
|
40
|
-
`
|
|
41
|
-
|
|
42
|
-
|
|
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
|
-
-
|
|
52
|
-
a superseded pre-reset validation pass can
|
|
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
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
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
|
|
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) ===
|
|
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
|
|
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(
|
|
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
|
|
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) ===
|
|
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
|
|
652
|
+
const stale = sequences.get(SUBMIT_SEQ_KEY) !== pass || store.getState().values !== valuesAtStart;
|
|
650
653
|
if (result.kind === "invalid") {
|
|
651
|
-
if (!
|
|
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 =
|
|
672
|
+
const merged = stale ? result.errors : store.getState().errors;
|
|
670
673
|
onInvalid?.(merged);
|
|
671
674
|
return { kind: "invalid", errors: merged };
|
|
672
675
|
}
|
|
673
|
-
if (!
|
|
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
|
-
|
|
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
|
|
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) =>
|
|
1374
|
-
focusableControls(root ?? document)
|
|
1375
|
-
|
|
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) => {
|