formstand 0.3.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 +110 -0
- package/README.md +26 -4
- package/dist/index.cjs +169 -56
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +26 -28
- package/dist/index.d.ts +26 -28
- package/dist/index.js +168 -55
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,115 @@
|
|
|
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
|
+
|
|
34
|
+
## 0.4.0 — 2026-07-08
|
|
35
|
+
|
|
36
|
+
### Breaking
|
|
37
|
+
|
|
38
|
+
- The deprecated `useFormState` / `useFormStateShallow` aliases are removed —
|
|
39
|
+
use `useFormSelector` / `useFormSelectorShallow` (renamed in 0.2.0 because
|
|
40
|
+
React DOM ships its own, deprecated, `useFormState`).
|
|
41
|
+
- Bound components' `path` prop is schema-typed when `form` is a
|
|
42
|
+
`Form<TSchema>` (`PathsOf<F>`): typo'd paths are now compile errors, and
|
|
43
|
+
runtime-built strings need the documented cast. A structural `FieldFormApi`
|
|
44
|
+
form keeps plain `string`.
|
|
45
|
+
- `focusFirstError`'s root-`""` fallback no longer fires under the default
|
|
46
|
+
`document` scope when the page holds more than one `<form>` — "first
|
|
47
|
+
control" would be a guess, so it returns `false`. Pass the form element
|
|
48
|
+
(e.g. via a ref) as `root`.
|
|
49
|
+
- `SubmitResult` gained `{ kind: "error", error }`: when `onValid` throws or
|
|
50
|
+
rejects, `submit` now **resolves** with that result instead of rejecting
|
|
51
|
+
(so `handleSubmit` never leaves an unhandled rejection). Code that relied
|
|
52
|
+
on catching the rejection must check `result.kind === "error"`.
|
|
53
|
+
- `adoptValues` now clears the in-flight validation flags
|
|
54
|
+
(`isValidating` / `isValidatingForm`) along with the errors it already
|
|
55
|
+
cleared — the rebase disowns in-flight passes.
|
|
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.
|
|
63
|
+
|
|
64
|
+
### Added
|
|
65
|
+
|
|
66
|
+
- `focusField(path, root?)` — imperative focus by path (the `setFocus` of
|
|
67
|
+
react-hook-form), sharing `focusFirstError`'s focusability rules.
|
|
68
|
+
- `emptyValueForSchema(schema)` is exported — the schema-introspection rule
|
|
69
|
+
behind `useField().emptyValue`, alongside its adapter siblings
|
|
70
|
+
`numberToInputText` / `parseNumberText`.
|
|
71
|
+
- `field.setError` (from `useField`) accepts a single string, matching
|
|
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.
|
|
82
|
+
- Docs: a migrating-from-react-hook-form guide with the full API mapping
|
|
83
|
+
table.
|
|
84
|
+
|
|
85
|
+
### Fixed
|
|
86
|
+
|
|
87
|
+
- Array ops no longer strand in-flight `isValidating` flags: flags under the
|
|
88
|
+
path are dropped rather than re-keyed (the completing pass clears the
|
|
89
|
+
original key, so a re-keyed flag could never be cleared).
|
|
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).
|
|
100
|
+
- `validateFieldAsync("")` delegates to the whole-form pass, so its pending
|
|
101
|
+
state lives in `isValidatingForm` instead of `isValidating[""]`.
|
|
102
|
+
- `focusFirstError` / `focusField` report success only when a control
|
|
103
|
+
actually **holds** focus — hidden, disabled, closed-`<dialog>`, and
|
|
104
|
+
focus-refusing matches are passed over for the next candidate in DOM
|
|
105
|
+
order.
|
|
106
|
+
- `dirtyFields()` / `diff()` report an object that diverges only by an
|
|
107
|
+
`undefined`-valued key, agreeing with `useIsDirty`.
|
|
108
|
+
- `SelectField` tolerates duplicate option values (React keys no longer
|
|
109
|
+
collide).
|
|
110
|
+
- `useField` typo errors blame the path argument against the full
|
|
111
|
+
`FieldPath` union instead of blaming the form argument.
|
|
112
|
+
|
|
3
113
|
## 0.3.0 — 2026-07-04
|
|
4
114
|
|
|
5
115
|
### Added
|
package/README.md
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
# formstand
|
|
2
2
|
|
|
3
|
+
[](https://github.com/Scrumrot/formstand/actions/workflows/ci.yml) [](https://www.npmjs.com/package/formstand) [](https://github.com/Scrumrot/formstand/blob/main/LICENSE)
|
|
4
|
+
|
|
3
5
|
Zod-schema-first form state for React 19, backed by zustand.
|
|
4
6
|
|
|
5
7
|
**[Documentation](https://scrumrot.github.io/formstand/)** · **[Live playground](https://scrumrot.github.io/formstand/examples/)** · **[API reference](https://scrumrot.github.io/formstand/api/)**
|
|
@@ -84,11 +86,11 @@ const form = createForm(schema, {
|
|
|
84
86
|
| `setMode` / `setReValidateMode` | switch modes at runtime |
|
|
85
87
|
| `reset(nextInitial?, options?)` | reset to initial; optional partial overrides (shallow-merged for record roots, replaced wholesale otherwise) and `{ keepErrors, keepTouched, keepSubmitCount }` (no `keepDirty` — dirtiness derives from values vs initial, and reset makes them equal) |
|
|
86
88
|
| `resetField(path)` | reset one field to its initial value, clearing its (and descendants') error/touched state (dirtiness clears by definition — the value now equals initial) |
|
|
87
|
-
| `adoptValues(values)` | mid-session rebase: replaces `values` + `initialValues` and clears `errors
|
|
89
|
+
| `adoptValues(values)` | mid-session rebase: replaces `values` + `initialValues` and clears `errors` and in-flight validation flags (`isValidating`/`isValidatingForm` — the rebase disowns in-flight passes), but **preserves** interaction state (`touched`, `submitCount`, `isSubmitting`, `mode`). Use `reset()` for a full wipe |
|
|
88
90
|
| `updateState(updater)` | atomic multi-field patch; `errors` is derived from `schemaErrors`/`serverErrors`, so patch the channels — the patch type omits `errors` entirely, and a plain-JS `errors` patch is warned about and ignored |
|
|
89
91
|
| `validate()` / `validateField(path)` / `validateFields(paths)` | sync validation; on an async schema they transparently start the async pass instead (`validate`/`validateField` return `{ kind: "pending", promise }`, `validateFields` returns the `Promise<boolean>` itself) |
|
|
90
92
|
| `validateAsync()` / `validateFieldAsync(path)` / `validateFieldsAsync(paths)` | async; supports `async .refine` |
|
|
91
|
-
| `submit(onValid, onInvalid?, { force? })` → `Promise<SubmitResult>` | full submit flow; resolves `{ kind: "valid", data }`, `{ kind: "invalid", errors }` (errored fields are also marked touched),
|
|
93
|
+
| `submit(onValid, onInvalid?, { force? })` → `Promise<SubmitResult>` | full submit flow; resolves `{ kind: "valid", data }`, `{ kind: "invalid", errors }` (errored fields are also marked touched), `{ kind: "skipped" }` when another submit is in flight, or `{ kind: "error", error }` when `onValid` throws/rejects — submit resolves instead of rejecting, so `handleSubmit` never produces an unhandled rejection from an `onValid` throw |
|
|
92
94
|
| `handleSubmit(onValid, onInvalid?)(event?)` | event handler wrapper that calls `preventDefault` |
|
|
93
95
|
| `getField(path)` | typed one-shot value read |
|
|
94
96
|
| `getFieldState(path)` | typed one-shot read of a field's full slice (value/error/touched/dirty/isValidating) |
|
|
@@ -127,7 +129,7 @@ name.dirty;
|
|
|
127
129
|
name.isValidating;
|
|
128
130
|
name.setValue(v);
|
|
129
131
|
name.setTouched();
|
|
130
|
-
name.setError([
|
|
132
|
+
name.setError("taken"); // or ["taken", "reserved"]
|
|
131
133
|
name.clearError();
|
|
132
134
|
name.validate();
|
|
133
135
|
name.validateAsync();
|
|
@@ -176,7 +178,7 @@ Selector-style subscription. Use `useFormSelectorShallow` for selectors that ret
|
|
|
176
178
|
|
|
177
179
|
> Formerly `useFormState` / `useFormStateShallow` — renamed because React DOM
|
|
178
180
|
> ships its own (deprecated) `useFormState` and auto-imports regularly grabbed
|
|
179
|
-
> the wrong one. The old names
|
|
181
|
+
> the wrong one. The old names were removed in 0.4.0.
|
|
180
182
|
|
|
181
183
|
### `useFormError(form)`
|
|
182
184
|
|
|
@@ -256,6 +258,20 @@ works out of the box. Wire it into the invalid-submit handler:
|
|
|
256
258
|
<form onSubmit={form.handleSubmit(onValid, (errors) => focusFirstError(errors))}>
|
|
257
259
|
```
|
|
258
260
|
|
|
261
|
+
On a page with several forms, pass the form element (e.g. via a ref) as `root`
|
|
262
|
+
so the search stays inside your form — without it the whole document is
|
|
263
|
+
scanned, and a root-`""` error's first-control fallback refuses to guess
|
|
264
|
+
between forms (it returns `false` when the document holds more than one
|
|
265
|
+
`<form>`):
|
|
266
|
+
|
|
267
|
+
```tsx
|
|
268
|
+
<form ref={formRef} onSubmit={form.handleSubmit(onValid, (errors) => focusFirstError(errors, formRef.current ?? undefined))}>
|
|
269
|
+
```
|
|
270
|
+
|
|
271
|
+
It returns `true` only when a control actually received focus — disabled or
|
|
272
|
+
hidden controls, controls inside a closed `<dialog>`, and controls that refuse
|
|
273
|
+
focus (e.g. `display: none`) are passed over for the next match.
|
|
274
|
+
|
|
259
275
|
For custom focus logic, every bound component also takes a `ref` to its input.
|
|
260
276
|
|
|
261
277
|
## Sharing a form across components
|
|
@@ -409,6 +425,12 @@ form.handleSubmit(async (data) => {
|
|
|
409
425
|
});
|
|
410
426
|
```
|
|
411
427
|
|
|
428
|
+
## Contributing
|
|
429
|
+
|
|
430
|
+
See [CONTRIBUTING.md](./CONTRIBUTING.md) — note the repo has three
|
|
431
|
+
independent npm roots (`.`, `examples/`, `cli/`), and the root test suite
|
|
432
|
+
needs `examples/` installed.
|
|
433
|
+
|
|
412
434
|
## License
|
|
413
435
|
|
|
414
436
|
MIT
|
package/dist/index.cjs
CHANGED
|
@@ -188,6 +188,39 @@ var emptyValueForSchema = (schema) => {
|
|
|
188
188
|
};
|
|
189
189
|
return walk(schema, false);
|
|
190
190
|
};
|
|
191
|
+
var unwrapForWalk = (s) => {
|
|
192
|
+
if (s instanceof zod.z.ZodOptional || s instanceof zod.z.ZodNullable) {
|
|
193
|
+
return s.unwrap();
|
|
194
|
+
}
|
|
195
|
+
if (s instanceof zod.z.ZodDefault || s instanceof zod.z.ZodPrefault || s instanceof zod.z.ZodCatch || s instanceof zod.z.ZodReadonly || s instanceof zod.z.ZodNonOptional) {
|
|
196
|
+
return s.def.innerType;
|
|
197
|
+
}
|
|
198
|
+
if (s instanceof zod.z.ZodPipe) return s.def.in;
|
|
199
|
+
return null;
|
|
200
|
+
};
|
|
201
|
+
var isLeafSchema = (s) => s instanceof zod.z.ZodString || s instanceof zod.z.ZodNumber || s instanceof zod.z.ZodBoolean || s instanceof zod.z.ZodBigInt || s instanceof zod.z.ZodDate || s instanceof zod.z.ZodEnum || s instanceof zod.z.ZodLiteral || s instanceof zod.z.ZodNull || s instanceof zod.z.ZodUndefined || s instanceof zod.z.ZodVoid || s instanceof zod.z.ZodNaN || s instanceof zod.z.ZodSymbol;
|
|
202
|
+
var SCHEMA_WALK_BUDGET = 64;
|
|
203
|
+
var schemaHasPath = (schema, path) => {
|
|
204
|
+
const walk = (s, segments, budget) => {
|
|
205
|
+
if (budget <= 0) return true;
|
|
206
|
+
const inner = unwrapForWalk(s);
|
|
207
|
+
if (inner !== null) return walk(inner, segments, budget - 1);
|
|
208
|
+
const [head, ...rest] = segments;
|
|
209
|
+
if (head === void 0) return true;
|
|
210
|
+
if (s instanceof zod.z.ZodObject) {
|
|
211
|
+
const shape = s.shape;
|
|
212
|
+
const child = shape[String(head)];
|
|
213
|
+
if (child !== void 0) return walk(child, rest, budget - 1);
|
|
214
|
+
const catchall = s.def.catchall;
|
|
215
|
+
return catchall !== void 0 && !(catchall instanceof zod.z.ZodNever);
|
|
216
|
+
}
|
|
217
|
+
if (s instanceof zod.z.ZodArray) {
|
|
218
|
+
return typeof head === "number" ? walk(s.element, rest, budget - 1) : false;
|
|
219
|
+
}
|
|
220
|
+
return !isLeafSchema(s);
|
|
221
|
+
};
|
|
222
|
+
return walk(schema, parsePath(path), SCHEMA_WALK_BUDGET);
|
|
223
|
+
};
|
|
191
224
|
var hasChecks = (s) => {
|
|
192
225
|
const checks = s.def.checks;
|
|
193
226
|
return checks !== void 0 && checks.length > 0;
|
|
@@ -229,17 +262,19 @@ var dirtyPathsOf = (values, initial, prefix) => {
|
|
|
229
262
|
if (valuesEqual(values, initial)) return [];
|
|
230
263
|
if (isPlainObject(values) && isPlainObject(initial)) {
|
|
231
264
|
const keys = /* @__PURE__ */ new Set([...Object.keys(values), ...Object.keys(initial)]);
|
|
232
|
-
|
|
265
|
+
const childPaths = [...keys].flatMap(
|
|
233
266
|
(k) => dirtyPathsOf(
|
|
234
267
|
values[k],
|
|
235
268
|
initial[k],
|
|
236
269
|
prefix === "" ? k : `${prefix}.${k}`
|
|
237
270
|
)
|
|
238
271
|
);
|
|
272
|
+
return childPaths.length > 0 ? childPaths : [prefix];
|
|
239
273
|
}
|
|
240
274
|
return [prefix];
|
|
241
275
|
};
|
|
242
276
|
var FORM_SEQ_KEY = "__form__";
|
|
277
|
+
var SUBMIT_SEQ_KEY = "__submit__";
|
|
243
278
|
var stringArraysEqual = (a, b) => a === b || a.length === b.length && a.every((v, i) => v === b[i]);
|
|
244
279
|
var reuseErrorRefs = (next, prev) => {
|
|
245
280
|
if (next === prev) return prev;
|
|
@@ -359,6 +394,16 @@ var createForm = (schema, options) => {
|
|
|
359
394
|
subschemaCache.set(path, sub);
|
|
360
395
|
return sub;
|
|
361
396
|
};
|
|
397
|
+
const checkedSchemaPaths = /* @__PURE__ */ new Set();
|
|
398
|
+
const warnMissingSchemaPath = (fn, path) => {
|
|
399
|
+
if (path === "" || checkedSchemaPaths.has(path)) return;
|
|
400
|
+
checkedSchemaPaths.add(path);
|
|
401
|
+
if (!schemaHasPath(schema, path)) {
|
|
402
|
+
console.warn(
|
|
403
|
+
`[formstand] ${fn}("${path}"): path does not exist in the schema \u2014 result will always be valid.`
|
|
404
|
+
);
|
|
405
|
+
}
|
|
406
|
+
};
|
|
362
407
|
const fieldScopeFor = (path, values) => {
|
|
363
408
|
const sub = path === "" ? null : subschemaAt(path);
|
|
364
409
|
if (sub === null) {
|
|
@@ -396,6 +441,7 @@ var createForm = (schema, options) => {
|
|
|
396
441
|
serverErrors: omitScope(state.serverErrors, [path])
|
|
397
442
|
});
|
|
398
443
|
const validateField = (path) => {
|
|
444
|
+
warnMissingSchemaPath("validateField", path);
|
|
399
445
|
try {
|
|
400
446
|
const scoped = scopedFieldErrors(path);
|
|
401
447
|
store.setState((state) => ({
|
|
@@ -421,18 +467,18 @@ var createForm = (schema, options) => {
|
|
|
421
467
|
};
|
|
422
468
|
};
|
|
423
469
|
const sequences = /* @__PURE__ */ new Map();
|
|
424
|
-
const
|
|
425
|
-
const
|
|
426
|
-
sequences.set(key,
|
|
427
|
-
return
|
|
470
|
+
const claimPass = (key) => {
|
|
471
|
+
const pass = Symbol(key);
|
|
472
|
+
sequences.set(key, pass);
|
|
473
|
+
return pass;
|
|
428
474
|
};
|
|
429
475
|
const validateAsyncOnForm = async () => {
|
|
430
|
-
const
|
|
476
|
+
const pass = claimPass(FORM_SEQ_KEY);
|
|
431
477
|
const valuesAtStart = store.getState().values;
|
|
432
478
|
store.setState((state) => ({ ...state, isValidatingForm: true }));
|
|
433
479
|
const result = await validateAsync(schema, valuesAtStart);
|
|
434
480
|
const live = store.getState();
|
|
435
|
-
const stillOwns = sequences.get(FORM_SEQ_KEY) ===
|
|
481
|
+
const stillOwns = sequences.get(FORM_SEQ_KEY) === pass;
|
|
436
482
|
if (!stillOwns || live.values !== valuesAtStart) {
|
|
437
483
|
if (stillOwns) {
|
|
438
484
|
store.setState((state) => ({ ...state, isValidatingForm: false }));
|
|
@@ -455,7 +501,7 @@ var createForm = (schema, options) => {
|
|
|
455
501
|
const result2 = await validateAsyncOnForm();
|
|
456
502
|
return result2.kind === "valid";
|
|
457
503
|
}
|
|
458
|
-
const
|
|
504
|
+
const passes = new Map(paths.map((p) => [p, claimPass(p)]));
|
|
459
505
|
const valuesAtStart = store.getState().values;
|
|
460
506
|
store.setState((state) => ({
|
|
461
507
|
...state,
|
|
@@ -470,7 +516,9 @@ var createForm = (schema, options) => {
|
|
|
470
516
|
(k) => !inScopeOfAny(k, paths)
|
|
471
517
|
);
|
|
472
518
|
const live = store.getState();
|
|
473
|
-
const stillCurrent = paths.filter(
|
|
519
|
+
const stillCurrent = paths.filter(
|
|
520
|
+
(p) => sequences.get(p) === passes.get(p)
|
|
521
|
+
);
|
|
474
522
|
if (live.values !== valuesAtStart) {
|
|
475
523
|
if (stillCurrent.length > 0) {
|
|
476
524
|
store.setState((state) => ({
|
|
@@ -504,7 +552,14 @@ var createForm = (schema, options) => {
|
|
|
504
552
|
return requestedValid;
|
|
505
553
|
};
|
|
506
554
|
const validateFieldAsync = async (path) => {
|
|
507
|
-
|
|
555
|
+
warnMissingSchemaPath("validateFieldAsync", path);
|
|
556
|
+
if (path === "") {
|
|
557
|
+
const result = await validateAsyncOnForm();
|
|
558
|
+
return fieldResult(
|
|
559
|
+
result.kind === "invalid" ? result.errors : emptyErrors
|
|
560
|
+
);
|
|
561
|
+
}
|
|
562
|
+
const pass = claimPass(path);
|
|
508
563
|
const valuesAtStart = store.getState().values;
|
|
509
564
|
store.setState((state) => ({
|
|
510
565
|
...state,
|
|
@@ -512,7 +567,7 @@ var createForm = (schema, options) => {
|
|
|
512
567
|
}));
|
|
513
568
|
const scoped = await scopedFieldErrorsAsync(path, valuesAtStart);
|
|
514
569
|
const live = store.getState();
|
|
515
|
-
const stillOwns = sequences.get(path) ===
|
|
570
|
+
const stillOwns = sequences.get(path) === pass;
|
|
516
571
|
if (!stillOwns || live.values !== valuesAtStart) {
|
|
517
572
|
if (stillOwns) {
|
|
518
573
|
store.setState((state) => ({
|
|
@@ -563,7 +618,13 @@ var createForm = (schema, options) => {
|
|
|
563
618
|
)
|
|
564
619
|
}),
|
|
565
620
|
touched: reKeyByArrayPath(state.touched, path, mapper),
|
|
566
|
-
|
|
621
|
+
// Drop (don't re-key) in-flight flags under the array: the op changes
|
|
622
|
+
// `values`, so every pass they belong to fails its values-changed
|
|
623
|
+
// guard and never commits — but each pass's cleanup omits its
|
|
624
|
+
// ORIGINAL key, so a re-keyed entry would be orphaned and stick
|
|
625
|
+
// forever. Dropped, the flag is gone now and the cleanup is a
|
|
626
|
+
// harmless no-op.
|
|
627
|
+
isValidating: omitScope(state.isValidating, [path])
|
|
567
628
|
};
|
|
568
629
|
});
|
|
569
630
|
};
|
|
@@ -585,33 +646,44 @@ var createForm = (schema, options) => {
|
|
|
585
646
|
submitCount: state.submitCount + 1
|
|
586
647
|
}));
|
|
587
648
|
try {
|
|
588
|
-
const
|
|
649
|
+
const pass = claimPass(SUBMIT_SEQ_KEY);
|
|
650
|
+
const valuesAtStart = store.getState().values;
|
|
651
|
+
const result = await validateAsync(schema, valuesAtStart);
|
|
652
|
+
const stale = sequences.get(SUBMIT_SEQ_KEY) !== pass || store.getState().values !== valuesAtStart;
|
|
589
653
|
if (result.kind === "invalid") {
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
Object.
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
654
|
+
if (!stale) {
|
|
655
|
+
store.setState((state) => {
|
|
656
|
+
const committed = commitFullPass(state, result.errors);
|
|
657
|
+
return {
|
|
658
|
+
...state,
|
|
659
|
+
...committed,
|
|
660
|
+
// Mark every errored field touched so touched-gated error UIs
|
|
661
|
+
// show something after the canonical first failed submit. The
|
|
662
|
+
// "" key (schema-level refine) has no field to touch.
|
|
663
|
+
touched: {
|
|
664
|
+
...state.touched,
|
|
665
|
+
...Object.fromEntries(
|
|
666
|
+
Object.keys(committed.errors).filter((k) => k !== "").map((k) => [k, true])
|
|
667
|
+
)
|
|
668
|
+
}
|
|
669
|
+
};
|
|
670
|
+
});
|
|
671
|
+
}
|
|
672
|
+
const merged = stale ? result.errors : store.getState().errors;
|
|
607
673
|
onInvalid?.(merged);
|
|
608
674
|
return { kind: "invalid", errors: merged };
|
|
609
675
|
}
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
676
|
+
if (!stale) {
|
|
677
|
+
store.setState((state) => ({
|
|
678
|
+
...state,
|
|
679
|
+
...commitFullPass(state, emptyErrors)
|
|
680
|
+
}));
|
|
681
|
+
}
|
|
682
|
+
try {
|
|
683
|
+
await onValid(result.data);
|
|
684
|
+
} catch (error) {
|
|
685
|
+
return { kind: "error", error };
|
|
686
|
+
}
|
|
615
687
|
return { kind: "valid", data: result.data };
|
|
616
688
|
} finally {
|
|
617
689
|
inFlight.count -= 1;
|
|
@@ -743,7 +815,12 @@ var createForm = (schema, options) => {
|
|
|
743
815
|
...errorChannels(state, {
|
|
744
816
|
schemaErrors: emptyErrors,
|
|
745
817
|
serverErrors: emptyErrors
|
|
746
|
-
})
|
|
818
|
+
}),
|
|
819
|
+
// Clearing `sequences` disowns every in-flight pass, including its
|
|
820
|
+
// flag cleanup (the ownership check fails, so it never writes) —
|
|
821
|
+
// clear the flags here too, mirroring reset(), or they'd stick.
|
|
822
|
+
isValidating: emptyBools,
|
|
823
|
+
isValidatingForm: false
|
|
747
824
|
}));
|
|
748
825
|
},
|
|
749
826
|
reset: (nextInitial, resetOptions) => {
|
|
@@ -849,7 +926,13 @@ var createForm = (schema, options) => {
|
|
|
849
926
|
errors: reuseErrorRefs(
|
|
850
927
|
mergeErrorChannels(schemaErrors, serverErrors),
|
|
851
928
|
snap.errors ?? emptyErrors
|
|
852
|
-
)
|
|
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
|
|
853
936
|
};
|
|
854
937
|
})
|
|
855
938
|
});
|
|
@@ -908,8 +991,6 @@ function useFormSelector(form, selector) {
|
|
|
908
991
|
function useFormSelectorShallow(form, selector) {
|
|
909
992
|
return react$1.useStore(form.store, shallow.useShallow(selector));
|
|
910
993
|
}
|
|
911
|
-
var useFormState = useFormSelector;
|
|
912
|
-
var useFormStateShallow = useFormSelectorShallow;
|
|
913
994
|
var useFormError = (form) => react$1.useStore(
|
|
914
995
|
form.store,
|
|
915
996
|
shallow.useShallow((state) => state.errors[""])
|
|
@@ -1007,7 +1088,10 @@ function useField(form, pathArg, options) {
|
|
|
1007
1088
|
[form, path]
|
|
1008
1089
|
);
|
|
1009
1090
|
const setError = react.useCallback(
|
|
1010
|
-
|
|
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),
|
|
1011
1095
|
[form, path]
|
|
1012
1096
|
);
|
|
1013
1097
|
const clearError = react.useCallback(
|
|
@@ -1206,7 +1290,12 @@ var NumberField = ({
|
|
|
1206
1290
|
/* @__PURE__ */ jsxRuntime.jsx(ErrorText, { id: errorId, error: field.error })
|
|
1207
1291
|
] });
|
|
1208
1292
|
};
|
|
1209
|
-
var CheckboxField = ({
|
|
1293
|
+
var CheckboxField = ({
|
|
1294
|
+
form,
|
|
1295
|
+
path,
|
|
1296
|
+
label,
|
|
1297
|
+
ref
|
|
1298
|
+
}) => {
|
|
1210
1299
|
const id = react.useId();
|
|
1211
1300
|
const errorId = `${id}-error`;
|
|
1212
1301
|
const field = useField(form, path);
|
|
@@ -1250,7 +1339,12 @@ var SelectField = ({
|
|
|
1250
1339
|
"aria-describedby": hasError(field.error) ? errorId : void 0,
|
|
1251
1340
|
children: [
|
|
1252
1341
|
showEmptyOption ? /* @__PURE__ */ jsxRuntime.jsx("option", { value: "", disabled: true, children: placeholder ?? "" }) : null,
|
|
1253
|
-
options.map((opt) =>
|
|
1342
|
+
options.map((opt, index) => (
|
|
1343
|
+
// Index-qualified: option lists may legitimately repeat a value
|
|
1344
|
+
// (e.g. differently-labelled aliases), and bare value keys would
|
|
1345
|
+
// collide.
|
|
1346
|
+
/* @__PURE__ */ jsxRuntime.jsx("option", { value: opt.value, children: opt.label }, `${index}-${opt.value}`)
|
|
1347
|
+
))
|
|
1254
1348
|
]
|
|
1255
1349
|
}
|
|
1256
1350
|
),
|
|
@@ -1259,24 +1353,43 @@ var SelectField = ({
|
|
|
1259
1353
|
};
|
|
1260
1354
|
|
|
1261
1355
|
// src/react/focusError.ts
|
|
1356
|
+
var focusableControls = (scope) => [
|
|
1357
|
+
...scope.querySelectorAll(
|
|
1358
|
+
"input[name], select[name], textarea[name]"
|
|
1359
|
+
)
|
|
1360
|
+
].filter(
|
|
1361
|
+
(el) => !el.matches(':disabled, [type="hidden"]') && el.closest("dialog:not([open])") === null
|
|
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
|
+
};
|
|
1368
|
+
var focusFirstOf = (candidates) => candidates.some((el) => {
|
|
1369
|
+
el.focus();
|
|
1370
|
+
return holdsFocus(el);
|
|
1371
|
+
});
|
|
1372
|
+
var nameMatchesAny = (el, paths) => {
|
|
1373
|
+
const name = el.getAttribute("name");
|
|
1374
|
+
return name !== null && paths.some((p) => isPathOrChild(name, p));
|
|
1375
|
+
};
|
|
1262
1376
|
var focusFirstError = (errors, root) => {
|
|
1263
1377
|
const scope = root ?? document;
|
|
1264
1378
|
const erroredPaths = Object.keys(errors).filter(
|
|
1265
1379
|
(k) => k !== "" && (errors[k]?.length ?? 0) > 0
|
|
1266
1380
|
);
|
|
1267
1381
|
const hasRootError = (errors[""]?.length ?? 0) > 0;
|
|
1268
|
-
const controls =
|
|
1269
|
-
|
|
1270
|
-
|
|
1271
|
-
|
|
1272
|
-
|
|
1273
|
-
const
|
|
1274
|
-
|
|
1275
|
-
|
|
1276
|
-
|
|
1277
|
-
const
|
|
1278
|
-
|
|
1279
|
-
return target !== void 0;
|
|
1382
|
+
const controls = focusableControls(scope);
|
|
1383
|
+
const fieldMatches = controls.filter(
|
|
1384
|
+
(el) => nameMatchesAny(el, erroredPaths)
|
|
1385
|
+
);
|
|
1386
|
+
const rootFallbackApplies = hasRootError && (root !== void 0 || document.querySelectorAll("form").length <= 1);
|
|
1387
|
+
const candidates = fieldMatches.length > 0 ? fieldMatches : rootFallbackApplies ? controls : [];
|
|
1388
|
+
return focusFirstOf(candidates);
|
|
1389
|
+
};
|
|
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])));
|
|
1280
1393
|
};
|
|
1281
1394
|
var EMPTY_ID_STATE = { items: [], ids: [], counter: 0 };
|
|
1282
1395
|
var EMPTY_ITEMS = [];
|
|
@@ -1390,7 +1503,9 @@ exports.TextField = TextField;
|
|
|
1390
1503
|
exports.checkboxProps = checkboxProps;
|
|
1391
1504
|
exports.createForm = createForm;
|
|
1392
1505
|
exports.createFormContext = createFormContext;
|
|
1506
|
+
exports.emptyValueForSchema = emptyValueForSchema;
|
|
1393
1507
|
exports.flattenIssues = flattenIssues;
|
|
1508
|
+
exports.focusField = focusField;
|
|
1394
1509
|
exports.focusFirstError = focusFirstError;
|
|
1395
1510
|
exports.getAtPath = getAtPath;
|
|
1396
1511
|
exports.isAsyncRequiredError = isAsyncRequiredError;
|
|
@@ -1408,8 +1523,6 @@ exports.useForm = useForm;
|
|
|
1408
1523
|
exports.useFormError = useFormError;
|
|
1409
1524
|
exports.useFormSelector = useFormSelector;
|
|
1410
1525
|
exports.useFormSelectorShallow = useFormSelectorShallow;
|
|
1411
|
-
exports.useFormState = useFormState;
|
|
1412
|
-
exports.useFormStateShallow = useFormStateShallow;
|
|
1413
1526
|
exports.useIsDirty = useIsDirty;
|
|
1414
1527
|
exports.useIsSubmitting = useIsSubmitting;
|
|
1415
1528
|
exports.useIsValid = useIsValid;
|