formstand 0.25.0 → 0.26.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/dist/index.js CHANGED
@@ -1,5 +1,5 @@
1
- import { isDevToolsBuild, getAtPath, isDevBuild, slotAtPath, setAtPath, isPlainObject, hasOwnSlot, valuesEqual, useFormSelectorShallow, isFieldDirty, useFormSelector, parsePath } from './chunk-EQIS5IVH.js';
2
- export { getAtPath, parsePath, setAtPath, useFormSelector, useFormSelectorShallow } from './chunk-EQIS5IVH.js';
1
+ import { isDevToolsBuild, isDevBuild, useFormSelectorShallow, useFormSelector } from './chunk-XJ6W5CKB.js';
2
+ export { useFormSelector, useFormSelectorShallow } from './chunk-XJ6W5CKB.js';
3
3
  import { flagAt, entryAt, shouldValidateOn } from './chunk-V3PDMTPP.js';
4
4
  export { shouldValidateOn } from './chunk-V3PDMTPP.js';
5
5
  import { createStore } from 'zustand/vanilla';
@@ -103,6 +103,162 @@ var clearArrayOpsUnder = (store, path, keepSelf = false) => {
103
103
  );
104
104
  related.forEach((p) => byPath.delete(p));
105
105
  };
106
+
107
+ // src/core/equality.ts
108
+ var isPlainObject = (v) => {
109
+ if (typeof v !== "object" || v === null) return false;
110
+ const proto = Object.getPrototypeOf(v);
111
+ return proto === Object.prototype || proto === null;
112
+ };
113
+ var valuesEqual = (a, b) => {
114
+ if (Object.is(a, b)) return true;
115
+ if (typeof a === "number" && typeof b === "number") return a === b;
116
+ if (a instanceof Date && b instanceof Date) {
117
+ const [ta, tb] = [a.getTime(), b.getTime()];
118
+ return ta === tb || Number.isNaN(ta) && Number.isNaN(tb);
119
+ }
120
+ if (Array.isArray(a) && Array.isArray(b)) {
121
+ return a.length === b.length && a.every((v, i) => valuesEqual(v, b[i]));
122
+ }
123
+ if (isPlainObject(a) && isPlainObject(b)) {
124
+ const aKeys = Object.keys(a);
125
+ const bKeys = Object.keys(b);
126
+ return aKeys.length === bKeys.length && aKeys.every((k) => Object.hasOwn(b, k) && valuesEqual(a[k], b[k]));
127
+ }
128
+ return false;
129
+ };
130
+ var dirtyCache = /* @__PURE__ */ new WeakMap();
131
+ var MAX_VERDICTS = 4;
132
+ var isFieldDirty = (value, initialValue) => {
133
+ if (Object.is(value, initialValue)) return false;
134
+ if (typeof value !== "object" || value === null) {
135
+ return !valuesEqual(value, initialValue);
136
+ }
137
+ const cached = dirtyCache.get(value) ?? [];
138
+ const hit = cached.find((v) => Object.is(v.initial, initialValue));
139
+ if (hit !== void 0) return hit.dirty;
140
+ const dirty = !valuesEqual(value, initialValue);
141
+ dirtyCache.set(
142
+ value,
143
+ [{ initial: initialValue, dirty }, ...cached].slice(0, MAX_VERDICTS)
144
+ );
145
+ return dirty;
146
+ };
147
+
148
+ // src/core/path.ts
149
+ var MAX_ARRAY_INDEX = 1e5;
150
+ var isIndexSegment = (s) => {
151
+ const n = Number(s);
152
+ return Number.isInteger(n) && n >= 0 && String(n) === s;
153
+ };
154
+ var PARSE_CACHE_MAX = 4096;
155
+ var parseCache = /* @__PURE__ */ new Map();
156
+ var parsePath = (path) => {
157
+ const cached = parseCache.get(path);
158
+ if (cached !== void 0) {
159
+ parseCache.delete(path);
160
+ parseCache.set(path, cached);
161
+ return cached;
162
+ }
163
+ const segments = Object.freeze(
164
+ path === "" ? [] : path.split(".").map((s) => isIndexSegment(s) ? Number(s) : s)
165
+ );
166
+ if (parseCache.size >= PARSE_CACHE_MAX) {
167
+ const oldest = parseCache.keys().next().value;
168
+ if (oldest !== void 0) parseCache.delete(oldest);
169
+ }
170
+ parseCache.set(path, segments);
171
+ return segments;
172
+ };
173
+ var isPlainRecord = (value) => value !== null && typeof value === "object" && !Array.isArray(value);
174
+ var readSegment = (obj, segment) => {
175
+ if (obj == null) return void 0;
176
+ if (Array.isArray(obj)) {
177
+ return typeof segment === "number" ? obj[segment] : void 0;
178
+ }
179
+ return isPlainRecord(obj) && Object.hasOwn(obj, String(segment)) ? obj[String(segment)] : void 0;
180
+ };
181
+ var getAtPath = (obj, path) => parsePath(path).reduce(readSegment, obj);
182
+ var slotAtPath = (obj, path) => {
183
+ const walked = parsePath(path).reduce(
184
+ (acc, segment) => {
185
+ const container = acc.current;
186
+ const addressable = typeof segment === "number" ? Array.isArray(container) ? segment < container.length : isPlainRecord(container) : isPlainRecord(container);
187
+ return !acc.ok || !addressable ? { ok: false, current: void 0 } : { ok: true, current: readSegment(container, segment) };
188
+ },
189
+ { ok: true, current: obj }
190
+ );
191
+ return walked.ok ? { exists: true, value: walked.current } : { exists: false };
192
+ };
193
+ var hasOwnSlot = (obj, path) => parsePath(path).reduce(
194
+ (acc, segment) => {
195
+ const container = acc.current;
196
+ const owns = Array.isArray(container) ? typeof segment === "number" && segment < container.length : isPlainRecord(container) && Object.hasOwn(container, String(segment));
197
+ return !acc.ok || !owns ? { ok: false, current: void 0 } : { ok: true, current: readSegment(container, segment) };
198
+ },
199
+ { ok: true, current: obj }
200
+ ).ok;
201
+ var writeIntoArray = (arr, index, rest, value, kindAt, prefix) => {
202
+ const length = Math.max(arr.length, index + 1);
203
+ const child = writeSegments(arr[index], rest, value, kindAt, [
204
+ ...prefix,
205
+ index
206
+ ]);
207
+ if (index < arr.length && child === arr[index]) return arr;
208
+ return Array.from({ length }, (_, i) => i === index ? child : arr[i]);
209
+ };
210
+ var refusesHugeIndex = (index) => {
211
+ if (index > MAX_ARRAY_INDEX) {
212
+ console.warn(
213
+ `[formstand] refusing to write array index ${index} (max ${MAX_ARRAY_INDEX}); value left unchanged.`
214
+ );
215
+ return true;
216
+ }
217
+ return false;
218
+ };
219
+ var writeSegments = (obj, segments, value, kindAt, prefix) => {
220
+ const [head, ...rest] = segments;
221
+ if (head === void 0) return value;
222
+ if (Array.isArray(obj)) {
223
+ if (typeof head !== "number") {
224
+ console.warn(
225
+ `[formstand] cannot write string key "${head}" into an array; value left unchanged.`
226
+ );
227
+ return obj;
228
+ }
229
+ if (refusesHugeIndex(head)) return obj;
230
+ return writeIntoArray(obj, head, rest, value, kindAt, prefix);
231
+ }
232
+ if (isPlainRecord(obj)) {
233
+ if (!isPlainObject(obj)) {
234
+ console.warn(
235
+ `[formstand] cannot write key "${String(head)}" through a non-plain object (Date/Map/Set/class instance); value left unchanged.`
236
+ );
237
+ return obj;
238
+ }
239
+ const key2 = String(head);
240
+ const child = writeSegments(obj[key2], rest, value, kindAt, [
241
+ ...prefix,
242
+ key2
243
+ ]);
244
+ if (child === obj[key2] && Object.hasOwn(obj, key2)) return obj;
245
+ return { ...obj, [key2]: child };
246
+ }
247
+ const kind = kindAt?.(prefix.map(String).join(".")) ?? (typeof head === "number" ? "array" : "record");
248
+ if (kind === "array") {
249
+ if (typeof head !== "number") {
250
+ console.warn(
251
+ `[formstand] cannot write string key "${head}" into an array; value left unchanged.`
252
+ );
253
+ return obj;
254
+ }
255
+ if (refusesHugeIndex(head)) return obj;
256
+ return writeIntoArray([], head, rest, value, kindAt, prefix);
257
+ }
258
+ const key = String(head);
259
+ return { [key]: writeSegments(void 0, rest, value, kindAt, [...prefix, key]) };
260
+ };
261
+ var setAtPath = (obj, path, value, containerKindAt2) => writeSegments(obj, parsePath(path), value, containerKindAt2, []);
106
262
  var issuePathToFormPath = (path) => path.filter((segment) => typeof segment !== "symbol").join(".");
107
263
  var expandIssue = (issue, prefix) => {
108
264
  const at = [...prefix, ...issue.path];
@@ -134,6 +290,12 @@ var validateAsync = async (schema, values) => {
134
290
  return result.success ? { kind: "valid", data: result.data } : { kind: "invalid", errors: flattenIssues(result.error.issues) };
135
291
  };
136
292
  var isAsyncRequiredError = (e) => e instanceof z.core.$ZodAsyncError || e instanceof Error && /Encountered Promise during synchronous parse/i.test(e.message);
293
+ var reportBackgroundFailure = (error) => {
294
+ console.error(
295
+ "[formstand] a background validation pass threw (an async refinement rejected). Its in-flight flags were released. The error:",
296
+ error
297
+ );
298
+ };
137
299
  var isPathOrChild = (key, path) => key === path || key.startsWith(`${path}.`);
138
300
  var prefixErrorKeys = (errors, path) => path === "" ? errors : Object.fromEntries(
139
301
  Object.entries(errors).map(([k, v]) => [
@@ -159,7 +321,11 @@ var unwrapForWalk = (s) => {
159
321
  if (s instanceof z.ZodDefault || s instanceof z.ZodPrefault || s instanceof z.ZodCatch || s instanceof z.ZodReadonly || s instanceof z.ZodNonOptional) {
160
322
  return s.def.innerType;
161
323
  }
162
- if (s instanceof z.ZodPipe) return s.def.in;
324
+ if (s instanceof z.ZodPipe) {
325
+ const input = s.def.in;
326
+ return input instanceof z.ZodTransform ? s.def.out : input;
327
+ }
328
+ if (s instanceof z.ZodLazy) return s.def.getter();
163
329
  return null;
164
330
  };
165
331
  var isLeafSchema = (s) => s instanceof z.ZodString || s instanceof z.ZodNumber || s instanceof z.ZodBoolean || s instanceof z.ZodBigInt || s instanceof z.ZodDate || s instanceof z.ZodEnum || s instanceof z.ZodLiteral || s instanceof z.ZodNull || s instanceof z.ZodUndefined || s instanceof z.ZodVoid || s instanceof z.ZodNaN || s instanceof z.ZodSymbol;
@@ -185,14 +351,71 @@ var schemaHasPath = (schema, path) => {
185
351
  };
186
352
  return walk(schema, parsePath(path), SCHEMA_WALK_BUDGET);
187
353
  };
354
+ var schemaAllowsKindChangeAt = (schema, path) => {
355
+ const flexible = (s) => s instanceof z.ZodUnion || s instanceof z.ZodDiscriminatedUnion || s instanceof z.ZodPipe || s instanceof z.ZodAny || s instanceof z.ZodUnknown || s instanceof z.ZodLazy || s instanceof z.ZodCatch || s.def.coerce === true;
356
+ const strip = (s) => {
357
+ if (s instanceof z.ZodOptional || s instanceof z.ZodNullable) {
358
+ return strip(s.unwrap());
359
+ }
360
+ if (s instanceof z.ZodDefault || s instanceof z.ZodPrefault || s instanceof z.ZodReadonly || s instanceof z.ZodNonOptional) {
361
+ return strip(s.def.innerType);
362
+ }
363
+ return s;
364
+ };
365
+ const walk = (s, segments, budget) => {
366
+ if (budget <= 0) return true;
367
+ const node = strip(s);
368
+ if (flexible(node)) return true;
369
+ const [head, ...rest] = segments;
370
+ if (head === void 0) return false;
371
+ if (node instanceof z.ZodObject) {
372
+ const shape = node.shape;
373
+ const child = Object.hasOwn(shape, String(head)) ? shape[String(head)] : void 0;
374
+ return child === void 0 ? true : walk(child, rest, budget - 1);
375
+ }
376
+ if (node instanceof z.ZodArray) {
377
+ return typeof head === "number" ? walk(node.element, rest, budget - 1) : true;
378
+ }
379
+ if (node instanceof z.ZodTuple && typeof head === "number") {
380
+ const item = node.def.items[head];
381
+ return item === void 0 ? true : walk(item, rest, budget - 1);
382
+ }
383
+ if (node instanceof z.ZodRecord) {
384
+ return walk(node.valueType, rest, budget - 1);
385
+ }
386
+ return true;
387
+ };
388
+ return walk(schema, parsePath(path), SCHEMA_WALK_BUDGET);
389
+ };
188
390
  var hasChecks = (s) => {
189
391
  const checks = s.def.checks;
190
392
  return checks !== void 0 && checks.length > 0;
191
393
  };
192
- var fieldSchemaForIntrospection = (schema, path) => {
193
- const childOf = (s, segment) => {
394
+ var optionClaims = (option, discriminator, tag) => {
395
+ const strip = (s) => {
396
+ const inner = unwrapForWalk(s);
397
+ return inner === null ? s : strip(inner);
398
+ };
399
+ const node = strip(option);
400
+ if (!(node instanceof z.ZodObject)) return false;
401
+ const shape = node.shape;
402
+ const at = Object.hasOwn(shape, discriminator) ? shape[discriminator] : void 0;
403
+ if (at === void 0) return false;
404
+ const leaf = strip(at);
405
+ if (leaf instanceof z.ZodLiteral) {
406
+ return leaf.def.values.includes(tag);
407
+ }
408
+ if (leaf instanceof z.ZodEnum) {
409
+ return Object.values(leaf.def.entries).includes(
410
+ tag
411
+ );
412
+ }
413
+ return false;
414
+ };
415
+ var fieldSchemaForIntrospection = (schema, path, values) => {
416
+ const childOf = (s, segment, holder) => {
194
417
  const inner = unwrapForWalk(s);
195
- if (inner !== null) return childOf(inner, segment);
418
+ if (inner !== null) return childOf(inner, segment, holder);
196
419
  if (s instanceof z.ZodObject && typeof segment === "string") {
197
420
  const shape = s.shape;
198
421
  return Object.hasOwn(shape, segment) ? shape[segment] ?? null : null;
@@ -206,19 +429,45 @@ var fieldSchemaForIntrospection = (schema, path) => {
206
429
  if (s instanceof z.ZodRecord) {
207
430
  return s.valueType;
208
431
  }
432
+ if (s instanceof z.ZodIntersection) {
433
+ return childOf(s.def.left, segment, holder) ?? childOf(s.def.right, segment, holder);
434
+ }
209
435
  if (s instanceof z.ZodDiscriminatedUnion || s instanceof z.ZodUnion) {
210
436
  const options = s.def.options;
211
- return options.reduce(
212
- (found, option) => found ?? childOf(option, segment),
437
+ const discriminator = s instanceof z.ZodDiscriminatedUnion ? s.def.discriminator : null;
438
+ const tag = discriminator !== null && typeof holder === "object" && holder !== null ? holder[discriminator] : void 0;
439
+ const live = discriminator === null || tag === void 0 ? void 0 : options.find((option) => optionClaims(option, discriminator, tag));
440
+ const candidates = live === void 0 ? options : [live];
441
+ return candidates.reduce(
442
+ (found, option) => found ?? childOf(option, segment, holder),
213
443
  null
214
444
  );
215
445
  }
216
446
  return null;
217
447
  };
218
- return parsePath(path).reduce(
219
- (current, segment) => current === null ? null : childOf(current, segment),
220
- schema
221
- );
448
+ const segments = parsePath(path);
449
+ return segments.reduce(
450
+ (acc, segment) => acc.s === null ? acc : {
451
+ s: childOf(
452
+ acc.s,
453
+ segment,
454
+ values === void 0 ? void 0 : getAtPath(values, acc.prefix)
455
+ ),
456
+ prefix: acc.prefix === "" ? String(segment) : `${acc.prefix}.${String(segment)}`
457
+ },
458
+ { s: schema, prefix: "" }
459
+ ).s;
460
+ };
461
+ var schemaAcceptsUndefined = (schema) => schema instanceof z.ZodOptional || schema instanceof z.ZodDefault || schema instanceof z.ZodPrefault || schema instanceof z.ZodNullable && schemaAcceptsUndefined(schema.unwrap());
462
+ var isObjectSchemaAt = (schema, path) => {
463
+ const sub = fieldSchemaForIntrospection(schema, path);
464
+ if (sub === null) return true;
465
+ const strip = (s) => {
466
+ const inner = unwrapForWalk(s);
467
+ return inner === null ? s : strip(inner);
468
+ };
469
+ const node = strip(sub);
470
+ return !(node instanceof z.ZodUnion || node instanceof z.ZodDiscriminatedUnion || node instanceof z.ZodRecord || node instanceof z.ZodMap || node instanceof z.ZodSet || node instanceof z.ZodArray || node instanceof z.ZodTuple || isLeafSchema(node));
222
471
  };
223
472
  var containerKindAt = (schema) => (prefix) => {
224
473
  const unwrapAll = (s) => {
@@ -269,7 +518,7 @@ var fieldSchemaAtPath = (schema, path) => parsePath(path).reduce((current, segme
269
518
  }, schema);
270
519
 
271
520
  // src/core/createForm.ts
272
- var shallowFieldEqual = (a, b) => Object.is(a.value, b.value) && a.error === b.error && a.touched === b.touched && a.dirty === b.dirty && a.isValidating === b.isValidating;
521
+ var shallowFieldEqual = (a, b) => Object.is(a.value, b.value) && Object.is(a.initialValue, b.initialValue) && a.error === b.error && a.touched === b.touched && a.dirty === b.dirty && a.isValidating === b.isValidating;
273
522
  var emptyErrors = {};
274
523
  var emptyBools = {};
275
524
  var identityMapper = (n) => n;
@@ -327,7 +576,27 @@ var reuseErrorRefs = (next, prev) => {
327
576
  const sameShape = entries.length === Object.keys(prev).length && entries.every(([k, v]) => entryAt(prev, k) === v);
328
577
  return sameShape ? prev : Object.fromEntries(entries);
329
578
  };
330
- var scopeSettledResult = (result, path, viaSubschema) => result.kind !== "invalid" ? emptyErrors : viaSubschema ? prefixErrorKeys(result.errors, path) : path === "" ? result.errors : pickScope(result.errors, [path]);
579
+ var noScopeErrors = { scoped: emptyErrors, full: null };
580
+ var scopeParse = (result, path, viaSubschema) => {
581
+ const errors = result.kind === "invalid" ? result.errors : emptyErrors;
582
+ return viaSubschema ? { scoped: prefixErrorKeys(errors, path), full: null } : {
583
+ scoped: path === "" ? errors : pickScope(errors, [path]),
584
+ full: errors
585
+ };
586
+ };
587
+ var pruneUnreported = (prev, full) => {
588
+ const entries = Object.entries(prev);
589
+ const kept = entries.filter(([k]) => Object.hasOwn(full, k));
590
+ const unchanged = kept.length === entries.length && kept.every(([k, list]) => full[k] === list);
591
+ return unchanged ? prev : Object.fromEntries(kept.map(([k, list]) => [k, full[k] ?? list]));
592
+ };
593
+ var dropEmptyLists = (map) => {
594
+ const entries = Object.entries(map);
595
+ const kept = entries.filter(
596
+ ([, list]) => list !== void 0 && list.length > 0
597
+ );
598
+ return kept.length === entries.length ? map : Object.fromEntries(kept);
599
+ };
331
600
  var mergeErrorChannels = (schema, server) => {
332
601
  const extra = Object.entries(server).filter(
333
602
  ([k]) => !Object.hasOwn(schema, k)
@@ -437,17 +706,17 @@ var createForm = (schema, options) => {
437
706
  const merged = scopes.reduce(
438
707
  (acc, { path, scope }) => scope.kind === "skip" ? acc : {
439
708
  ...acc,
440
- ...scopeSettledResult(
709
+ ...scopeParse(
441
710
  validateSyncLatched(path, scope.schema, scope.value),
442
711
  path,
443
712
  true
444
- )
713
+ ).scoped
445
714
  },
446
715
  {}
447
716
  );
448
717
  store.setState((state) => ({
449
718
  ...state,
450
- ...commitScopedErrors(state, merged, paths)
719
+ ...commitScopedErrors(state, merged, paths, null)
451
720
  }));
452
721
  return settledFields(merged);
453
722
  }
@@ -456,7 +725,7 @@ var createForm = (schema, options) => {
456
725
  const requestedErrors = pickScope(fullErrors, paths);
457
726
  store.setState((state) => ({
458
727
  ...state,
459
- ...commitScopedErrors(state, requestedErrors, paths)
728
+ ...commitScopedErrors(state, requestedErrors, paths, fullErrors)
460
729
  }));
461
730
  return settledFields(requestedErrors);
462
731
  } catch (e) {
@@ -501,7 +770,7 @@ var createForm = (schema, options) => {
501
770
  const slot = slotAtPath(values, path);
502
771
  return slot.exists ? { kind: "parse", schema: sub, value: slot.value, viaSubschema: true } : { kind: "skip" };
503
772
  };
504
- const parseScopeAsync = async (path, scope) => scope.kind === "skip" ? emptyErrors : scopeSettledResult(
773
+ const parseScopeAsync = async (path, scope) => scope.kind === "skip" ? noScopeErrors : scopeParse(
505
774
  await validateAsync(scope.schema, scope.value),
506
775
  path,
507
776
  scope.viaSubschema
@@ -521,14 +790,18 @@ var createForm = (schema, options) => {
521
790
  };
522
791
  const settledFields = (scoped) => Object.keys(scoped).length === 0 ? { kind: "valid" } : { kind: "invalid", errors: scoped };
523
792
  const settledFormResult = (result) => settledFields(result.kind === "invalid" ? result.errors : emptyErrors);
524
- const commitScopedErrors = (state, scoped, paths) => errorChannels(state, {
793
+ const commitScopedErrors = (state, scoped, paths, full) => errorChannels(state, {
525
794
  schemaErrors: reuseErrorRefs(
526
- mergeScopedErrors(state.schemaErrors, scoped, paths),
795
+ mergeScopedErrors(
796
+ full === null ? state.schemaErrors : pruneUnreported(state.schemaErrors, full),
797
+ scoped,
798
+ paths
799
+ ),
527
800
  state.schemaErrors
528
801
  ),
529
802
  serverErrors: omitScope(state.serverErrors, paths)
530
803
  });
531
- const commitFieldErrors = (state, scoped, path) => path === "" ? commitFullPass(state, scoped) : commitScopedErrors(state, scoped, [path]);
804
+ const commitFieldErrors = (state, parse, path) => path === "" ? commitFullPass(state, parse.scoped) : commitScopedErrors(state, parse.scoped, [path], parse.full);
532
805
  const validateField = (path) => {
533
806
  warnMissingSchemaPath("validateField", path);
534
807
  const scope = fieldScopeFor(path, store.getState().values);
@@ -536,7 +809,7 @@ var createForm = (schema, options) => {
536
809
  return { kind: "pending", promise: validateFieldAsync(path) };
537
810
  }
538
811
  try {
539
- const scoped = scope.kind === "skip" ? emptyErrors : scopeSettledResult(
812
+ const parse = scope.kind === "skip" ? noScopeErrors : scopeParse(
540
813
  validateSyncLatched(
541
814
  scope.viaSubschema ? path : "",
542
815
  scope.schema,
@@ -547,9 +820,9 @@ var createForm = (schema, options) => {
547
820
  );
548
821
  store.setState((state) => ({
549
822
  ...state,
550
- ...commitFieldErrors(state, scoped, path)
823
+ ...commitFieldErrors(state, parse, path)
551
824
  }));
552
- return fieldResult(scoped);
825
+ return fieldResult(parse.scoped);
553
826
  } catch (e) {
554
827
  if (isAsyncRequiredError(e)) {
555
828
  return { kind: "pending", promise: validateFieldAsync(path) };
@@ -559,11 +832,15 @@ var createForm = (schema, options) => {
559
832
  };
560
833
  const snapshotField = (state, path) => {
561
834
  const value = getAtPath(state.values, path);
835
+ const initialValue = getAtPath(state.initialValues, path);
836
+ const error = entryAt(state.errors, path);
562
837
  return {
563
838
  value,
564
- error: entryAt(state.errors, path),
839
+ initialValue,
840
+ error,
841
+ firstError: error?.[0],
565
842
  touched: flagAt(state.touched, path),
566
- dirty: isFieldDirty(value, getAtPath(state.initialValues, path)),
843
+ dirty: isFieldDirty(value, initialValue),
567
844
  isValidating: flagAt(state.isValidating, path)
568
845
  };
569
846
  };
@@ -577,7 +854,14 @@ var createForm = (schema, options) => {
577
854
  const pass = claimPass(FORM_SEQ_KEY);
578
855
  const valuesAtStart = store.getState().values;
579
856
  store.setState((state) => ({ ...state, isValidatingForm: true }));
580
- const result = await validateAsync(schema, valuesAtStart);
857
+ const result = await validateAsync(schema, valuesAtStart).catch(
858
+ (error) => {
859
+ if (sequences.get(FORM_SEQ_KEY) === pass) {
860
+ store.setState((state) => ({ ...state, isValidatingForm: false }));
861
+ }
862
+ throw error;
863
+ }
864
+ );
581
865
  const live = store.getState();
582
866
  const stillOwns = sequences.get(FORM_SEQ_KEY) === pass;
583
867
  if (!stillOwns || live.values !== valuesAtStart) {
@@ -617,12 +901,15 @@ var createForm = (schema, options) => {
617
901
  ...Object.fromEntries(paths.map((p) => [p, true]))
618
902
  }
619
903
  }));
620
- const parsed = allViaSubschema ? {
904
+ const parseBatch = async () => allViaSubschema ? {
621
905
  kind: "slots",
622
906
  byPath: new Map(
623
907
  await Promise.all(
624
908
  scopes.map(
625
- async ({ path, scope }) => [path, await parseScopeAsync(path, scope)]
909
+ async ({ path, scope }) => [
910
+ path,
911
+ (await parseScopeAsync(path, scope)).scoped
912
+ ]
626
913
  )
627
914
  )
628
915
  )
@@ -632,6 +919,18 @@ var createForm = (schema, options) => {
632
919
  (result) => result.kind === "invalid" ? result.errors : emptyErrors
633
920
  )
634
921
  };
922
+ const parsed = await parseBatch().catch((error) => {
923
+ const stillOwned = new Set(
924
+ paths.filter((p) => sequences.get(p) === passes.get(p))
925
+ );
926
+ if (stillOwned.size > 0) {
927
+ store.setState((state) => ({
928
+ ...state,
929
+ isValidating: omitKeys(state.isValidating, stillOwned)
930
+ }));
931
+ }
932
+ throw error;
933
+ });
635
934
  const errorsFor = (path) => {
636
935
  switch (parsed.kind) {
637
936
  case "slots":
@@ -660,7 +959,15 @@ var createForm = (schema, options) => {
660
959
  Object.fromEntries(
661
960
  fresh.flatMap((path) => Object.entries(errorsFor(path)))
662
961
  ),
663
- fresh
962
+ fresh,
963
+ // The prune map describes the values the batch STARTED with.
964
+ // A skip scope (an index into an empty array) still reads as
965
+ // fresh after an unrelated change, so a batch can commit under
966
+ // changed values; pruning with the stale map then dropped
967
+ // errors committed elsewhere in flight (an email error landing
968
+ // while a wizard step with such a path validated). The prune
969
+ // rides only while the whole values reference held.
970
+ parsed.kind === "full" && live.values === valuesAtStart ? parsed.errors : null
664
971
  ),
665
972
  isValidating: omitKeys(state.isValidating, ownedKeys)
666
973
  }));
@@ -681,24 +988,32 @@ var createForm = (schema, options) => {
681
988
  ...state,
682
989
  isValidating: { ...state.isValidating, [path]: true }
683
990
  }));
684
- const scoped = await parseScopeAsync(path, scope);
685
- const live = store.getState();
686
- const stillOwns = sequences.get(path) === pass;
687
- if (!stillOwns || scopeStale(scope, path, valuesAtStart, live.values)) {
688
- if (stillOwns) {
991
+ const releaseOwnFlag = () => {
992
+ if (sequences.get(path) === pass) {
689
993
  store.setState((state) => ({
690
994
  ...state,
691
995
  isValidating: omitKey(state.isValidating, path)
692
996
  }));
693
997
  }
694
- return fieldResult(scoped);
998
+ };
999
+ const parse = await parseScopeAsync(path, scope).catch(
1000
+ (error) => {
1001
+ releaseOwnFlag();
1002
+ throw error;
1003
+ }
1004
+ );
1005
+ const live = store.getState();
1006
+ const stillOwns = sequences.get(path) === pass;
1007
+ if (!stillOwns || scopeStale(scope, path, valuesAtStart, live.values)) {
1008
+ releaseOwnFlag();
1009
+ return fieldResult(parse.scoped);
695
1010
  }
696
1011
  store.setState((state) => ({
697
1012
  ...state,
698
- ...commitFieldErrors(state, scoped, path),
1013
+ ...commitFieldErrors(state, parse, path),
699
1014
  isValidating: omitKey(state.isValidating, path)
700
1015
  }));
701
- return fieldResult(scoped);
1016
+ return fieldResult(parse.scoped);
702
1017
  };
703
1018
  const applyArrayOp = (path, nextArray, mapper, indexInBounds) => {
704
1019
  const current = getAtPath(store.getState().values, path);
@@ -757,7 +1072,9 @@ var createForm = (schema, options) => {
757
1072
  count: 0,
758
1073
  baseline: false
759
1074
  };
760
- const submit = async (onValid, onInvalid, submitOptions) => {
1075
+ const submit = async (onValid, onInvalidOrOptions, positionalOptions) => {
1076
+ const submitOptions = typeof onInvalidOrOptions === "object" ? onInvalidOrOptions : positionalOptions;
1077
+ const onInvalid = typeof onInvalidOrOptions === "function" ? onInvalidOrOptions : submitOptions?.onInvalid;
761
1078
  if (inFlight.count > 0 && submitOptions?.force !== true) {
762
1079
  return { kind: "skipped" };
763
1080
  }
@@ -798,6 +1115,7 @@ var createForm = (schema, options) => {
798
1115
  try {
799
1116
  onInvalid?.(merged);
800
1117
  } catch (error) {
1118
+ submitOptions?.onError?.(error);
801
1119
  return { kind: "error", error };
802
1120
  }
803
1121
  return { kind: "invalid", errors: merged };
@@ -823,9 +1141,14 @@ var createForm = (schema, options) => {
823
1141
  }
824
1142
  }
825
1143
  };
826
- if (options.validateOnMount === true) {
827
- validate();
828
- }
1144
+ const judgeBaseline = () => {
1145
+ if (options.validateOnMount !== true) return;
1146
+ const result = validate();
1147
+ if (result.kind === "pending") {
1148
+ void result.promise.catch(reportBackgroundFailure);
1149
+ }
1150
+ };
1151
+ judgeBaseline();
829
1152
  return Object.freeze({
830
1153
  schema,
831
1154
  store,
@@ -840,8 +1163,12 @@ var createForm = (schema, options) => {
840
1163
  return store.subscribe((state) => {
841
1164
  const next = snapshotField(state, path);
842
1165
  if (!shallowFieldEqual(ref.current, next)) {
1166
+ const prev = ref.current;
843
1167
  ref.current = next;
844
- listener(next);
1168
+ listener(
1169
+ next,
1170
+ prev
1171
+ );
845
1172
  }
846
1173
  });
847
1174
  },
@@ -876,10 +1203,12 @@ var createForm = (schema, options) => {
876
1203
  if (Object.is(getAtPath(currentValues, path), value) && (value !== void 0 || hasOwnSlot(currentValues, path))) {
877
1204
  return;
878
1205
  }
1206
+ const next = setAtPath(currentValues, path, value, kindAt);
1207
+ if (next === currentValues) return;
879
1208
  clearArrayOpsUnder(store, path);
880
1209
  store.setState((state) => ({
881
1210
  ...state,
882
- values: setAtPath(state.values, path, value, kindAt),
1211
+ values: next,
883
1212
  ...errorChannels(state, {
884
1213
  serverErrors: releaseSpine(state.serverErrors, path)
885
1214
  })
@@ -917,7 +1246,7 @@ var createForm = (schema, options) => {
917
1246
  },
918
1247
  setErrors: (errors) => store.setState((state) => ({
919
1248
  ...state,
920
- ...errorChannels(state, { serverErrors: errors })
1249
+ ...errorChannels(state, { serverErrors: dropEmptyLists(errors) })
921
1250
  })),
922
1251
  addErrors: (errors) => store.setState((state) => {
923
1252
  const entries = Object.entries(errors);
@@ -960,11 +1289,11 @@ var createForm = (schema, options) => {
960
1289
  if (next.values !== state.values) {
961
1290
  clearArrayOpsUnder(store, "");
962
1291
  }
963
- const serverErrors = patch.serverErrors !== void 0 ? patch.serverErrors : next.values !== state.values ? releaseChangedSlices(state.serverErrors, state.values, next.values) : state.serverErrors;
1292
+ const serverErrors = patch.serverErrors !== void 0 ? dropEmptyLists(patch.serverErrors) : next.values !== state.values ? releaseChangedSlices(state.serverErrors, state.values, next.values) : state.serverErrors;
964
1293
  store.setState(() => ({
965
1294
  ...next,
966
1295
  ...errorChannels(state, {
967
- schemaErrors: next.schemaErrors,
1296
+ schemaErrors: patch.schemaErrors === void 0 ? state.schemaErrors : dropEmptyLists(patch.schemaErrors),
968
1297
  serverErrors
969
1298
  })
970
1299
  }));
@@ -986,6 +1315,7 @@ var createForm = (schema, options) => {
986
1315
  isValidating: emptyBools,
987
1316
  isValidatingForm: false
988
1317
  }));
1318
+ judgeBaseline();
989
1319
  },
990
1320
  reset: (nextInitial, resetOptions) => {
991
1321
  sequences.clear();
@@ -1010,6 +1340,7 @@ var createForm = (schema, options) => {
1010
1340
  isValidatingForm: false
1011
1341
  };
1012
1342
  });
1343
+ if (resetOptions?.keepErrors !== true) judgeBaseline();
1013
1344
  },
1014
1345
  resetField: (path) => {
1015
1346
  const initialSlot = slotAtPath(
@@ -1073,9 +1404,14 @@ var createForm = (schema, options) => {
1073
1404
  swapIndices(a, b),
1074
1405
  (len) => Number.isInteger(a) && Number.isInteger(b) && a >= 0 && a < len && b >= 0 && b < len
1075
1406
  ),
1076
- handleSubmit: (onValid, onInvalid, opts) => async (event) => {
1407
+ handleSubmit: (onValid, onInvalidOrOptions, positionalOptions) => async (event) => {
1077
1408
  event?.preventDefault();
1078
- const result = await submit(onValid, onInvalid, opts);
1409
+ const opts = typeof onInvalidOrOptions === "object" ? onInvalidOrOptions : positionalOptions;
1410
+ const result = await submit(
1411
+ onValid,
1412
+ onInvalidOrOptions,
1413
+ positionalOptions
1414
+ );
1079
1415
  if (result.kind === "error" && opts?.onError === void 0 && isDevBuild()) {
1080
1416
  console.warn(
1081
1417
  "[formstand] submit handler threw and nothing observed it \u2014 the form shows no feedback. Pass { onError } to handleSubmit (or setError inside your handler):",
@@ -1099,10 +1435,11 @@ var createForm = (schema, options) => {
1099
1435
  },
1100
1436
  snapshot: () => store.getState(),
1101
1437
  restore: (snap) => {
1438
+ sequences.clear();
1102
1439
  clearArrayOpsUnder(store, "");
1103
1440
  store.setState(() => {
1104
- const schemaErrors = snap.schemaErrors ?? emptyErrors;
1105
- const serverErrors = snap.serverErrors ?? emptyErrors;
1441
+ const schemaErrors = dropEmptyLists(snap.schemaErrors ?? emptyErrors);
1442
+ const serverErrors = dropEmptyLists(snap.serverErrors ?? emptyErrors);
1106
1443
  return {
1107
1444
  ...snap,
1108
1445
  schemaErrors,
@@ -1132,11 +1469,13 @@ var createDebouncer = (ms) => {
1132
1469
  const timer = {
1133
1470
  current: null
1134
1471
  };
1472
+ const scheduled = { current: null };
1135
1473
  const cancel = () => {
1136
1474
  if (timer.current !== null) {
1137
1475
  clearTimeout(timer.current);
1138
1476
  timer.current = null;
1139
1477
  }
1478
+ scheduled.current = null;
1140
1479
  };
1141
1480
  const schedule = (run) => {
1142
1481
  cancel();
@@ -1144,37 +1483,50 @@ var createDebouncer = (ms) => {
1144
1483
  run();
1145
1484
  return;
1146
1485
  }
1486
+ scheduled.current = run;
1147
1487
  timer.current = setTimeout(() => {
1148
1488
  timer.current = null;
1489
+ scheduled.current = null;
1149
1490
  run();
1150
1491
  }, ms);
1151
1492
  };
1493
+ const flush = () => {
1494
+ const run = scheduled.current;
1495
+ if (run === null) return;
1496
+ cancel();
1497
+ run();
1498
+ };
1152
1499
  return Object.freeze({
1153
1500
  schedule,
1154
1501
  cancel,
1502
+ flush,
1155
1503
  pending: () => timer.current !== null
1156
1504
  });
1157
1505
  };
1158
1506
 
1159
1507
  // src/core/persist.ts
1160
1508
  var kindOf = (value) => value === null ? "null" : Array.isArray(value) ? "array" : typeof value;
1161
- var conflictsWith = (draft, reference) => {
1162
- if (draft === void 0 || draft === null) return false;
1163
- if (reference === void 0 || reference === null) return false;
1509
+ var joinPath = (base, key) => base === "" ? String(key) : `${base}.${String(key)}`;
1510
+ var conflictPath = (draft, reference, path, flexibleAt) => {
1511
+ if (draft === void 0 || draft === null) return null;
1512
+ if (reference === void 0 || reference === null) return null;
1164
1513
  const draftKind = kindOf(draft);
1165
1514
  const referenceKind = kindOf(reference);
1166
- if (draftKind !== referenceKind) return true;
1515
+ if (draftKind !== referenceKind) return flexibleAt(path) ? null : path;
1167
1516
  if (draftKind === "array") {
1168
1517
  const [d] = draft;
1169
1518
  const [r] = reference;
1170
- return conflictsWith(d, r);
1519
+ return conflictPath(d, r, joinPath(path, 0), flexibleAt);
1171
1520
  }
1172
1521
  if (draftKind === "object") {
1173
1522
  const d = draft;
1174
1523
  const r = reference;
1175
- return Object.keys(d).some((key) => conflictsWith(d[key], r[key]));
1524
+ return Object.keys(d).reduce(
1525
+ (found, key) => found ?? conflictPath(d[key], r[key], joinPath(path, key), flexibleAt),
1526
+ null
1527
+ );
1176
1528
  }
1177
- return false;
1529
+ return null;
1178
1530
  };
1179
1531
  var defaultStorage = () => {
1180
1532
  try {
@@ -1187,7 +1539,49 @@ var persistForm = (form, options) => {
1187
1539
  const storage = options.storage ?? defaultStorage();
1188
1540
  const debounceMs = options.debounceMs ?? 300;
1189
1541
  const apply = options.apply ?? "adopt";
1190
- const applyDraft = (values) => apply === "edit" || apply === "restore" ? form.setValues(values) : form.adoptValues(values);
1542
+ const schema = form.schema;
1543
+ const fillRequiredGaps = (draft, initial, path) => {
1544
+ if (!isPlainObject(draft) || !isPlainObject(initial)) return draft;
1545
+ if (!isObjectSchemaAt(schema, path)) return draft;
1546
+ return Object.keys(initial).reduce(
1547
+ (acc, key) => {
1548
+ const childPath = joinPath(path, key);
1549
+ if (Object.hasOwn(draft, key)) {
1550
+ const next = fillRequiredGaps(draft[key], initial[key], childPath);
1551
+ return Object.is(next, draft[key]) ? acc : { ...acc, [key]: next };
1552
+ }
1553
+ if (initial[key] === void 0) return acc;
1554
+ const sub = fieldSchemaForIntrospection(schema, childPath);
1555
+ return sub !== null && schemaAcceptsUndefined(sub) ? acc : { ...acc, [key]: initial[key] };
1556
+ },
1557
+ draft
1558
+ );
1559
+ };
1560
+ const applying = { current: false };
1561
+ const applyDraft = (values) => {
1562
+ const filled = fillRequiredGaps(
1563
+ values,
1564
+ form.getState().initialValues,
1565
+ ""
1566
+ );
1567
+ applying.current = true;
1568
+ try {
1569
+ if (apply === "edit" || apply === "restore") {
1570
+ form.setValues(filled);
1571
+ } else {
1572
+ form.adoptValues(filled);
1573
+ }
1574
+ } finally {
1575
+ applying.current = false;
1576
+ }
1577
+ };
1578
+ const isCurrent = (values) => {
1579
+ try {
1580
+ return JSON.stringify(values) === JSON.stringify(form.getState().values);
1581
+ } catch {
1582
+ return false;
1583
+ }
1584
+ };
1191
1585
  const referenceShape = () => {
1192
1586
  try {
1193
1587
  return JSON.parse(JSON.stringify(form.getState().initialValues));
@@ -1195,31 +1589,70 @@ var persistForm = (form, options) => {
1195
1589
  return void 0;
1196
1590
  }
1197
1591
  };
1198
- const load = () => {
1592
+ const flexibleAt = (path) => schemaAllowsKindChangeAt(schema, path);
1593
+ const inspect = (skipWhenCurrent) => {
1594
+ const raw = (() => {
1595
+ try {
1596
+ return storage === null ? null : storage.getItem(options.key);
1597
+ } catch {
1598
+ return null;
1599
+ }
1600
+ })();
1601
+ if (raw === null) return { kind: "absent" };
1199
1602
  try {
1200
- const raw = storage === null ? null : storage.getItem(options.key);
1201
- if (raw === null) return false;
1202
1603
  const parsed = JSON.parse(raw);
1203
1604
  const versioned = options.version !== void 0 && typeof parsed === "object" && parsed !== null && "__v" in parsed;
1204
- if (options.version !== void 0 && !versioned) return false;
1605
+ if (options.version !== void 0 && !versioned) {
1606
+ return {
1607
+ kind: "ignored",
1608
+ reason: `it carries no version and this configuration expects version ${String(options.version)}`
1609
+ };
1610
+ }
1205
1611
  if (options.version === void 0 && typeof parsed === "object" && parsed !== null && Object.hasOwn(parsed, "__v") && Object.hasOwn(parsed, "values")) {
1206
- return false;
1612
+ return {
1613
+ kind: "ignored",
1614
+ reason: "it was written with a version and this configuration has none"
1615
+ };
1207
1616
  }
1208
1617
  const wrapper = parsed;
1209
- if (versioned && wrapper.__v !== options.version) return false;
1210
- if (versioned && wrapper.values === void 0) return false;
1618
+ if (versioned && wrapper.__v !== options.version) {
1619
+ return {
1620
+ kind: "ignored",
1621
+ reason: `its version (${String(wrapper.__v)}) is not the configured version (${String(options.version)})`
1622
+ };
1623
+ }
1624
+ if (versioned && wrapper.values === void 0) {
1625
+ return { kind: "ignored", reason: "its version wrapper holds no values" };
1626
+ }
1211
1627
  const values = versioned ? wrapper.values : parsed;
1212
- if (values === null || values === void 0) return false;
1628
+ if (values === null || values === void 0) {
1629
+ return { kind: "ignored", reason: "it holds no values" };
1630
+ }
1213
1631
  const reference = referenceShape();
1214
- if (reference !== void 0 && conflictsWith(values, reference)) {
1215
- return false;
1632
+ const conflict = reference === void 0 ? null : conflictPath(values, reference, "", flexibleAt);
1633
+ if (conflict !== null) {
1634
+ return {
1635
+ kind: "ignored",
1636
+ reason: `its shape conflicts with the form's initial values at "${conflict}" (set the version option when the schema changes)`
1637
+ };
1216
1638
  }
1639
+ if (skipWhenCurrent && isCurrent(values)) return { kind: "applied" };
1217
1640
  applyDraft(values);
1218
- return true;
1641
+ return { kind: "applied" };
1219
1642
  } catch {
1220
- return false;
1643
+ return { kind: "ignored", reason: "it is not valid JSON" };
1221
1644
  }
1222
1645
  };
1646
+ const loadWith = (skipWhenCurrent) => {
1647
+ const outcome = inspect(skipWhenCurrent);
1648
+ if (outcome.kind === "ignored" && isDevBuild()) {
1649
+ console.warn(
1650
+ `[formstand] persistForm: ignored the draft under "${options.key}": ${outcome.reason}. The next edit overwrites it.`
1651
+ );
1652
+ }
1653
+ return outcome.kind === "applied";
1654
+ };
1655
+ const load = () => loadWith(false);
1223
1656
  const writeDraft = (values) => {
1224
1657
  try {
1225
1658
  storage?.setItem(
@@ -1232,24 +1665,29 @@ var persistForm = (form, options) => {
1232
1665
  }
1233
1666
  };
1234
1667
  const writes = createDebouncer(debounceMs);
1235
- const cancelPending = () => writes.cancel();
1236
1668
  if (apply !== "manual") {
1237
- load();
1669
+ loadWith(true);
1238
1670
  }
1671
+ const cleared = { current: false };
1239
1672
  const unsubscribe = form.watchValues((values) => {
1673
+ if (applying.current) return;
1674
+ if (cleared.current && values === form.getState().initialValues) return;
1675
+ cleared.current = false;
1240
1676
  writes.schedule(() => writeDraft(values));
1241
1677
  });
1242
1678
  return Object.freeze({
1243
1679
  load,
1680
+ flush: () => writes.flush(),
1244
1681
  clear: () => {
1245
- cancelPending();
1682
+ writes.cancel();
1683
+ cleared.current = true;
1246
1684
  try {
1247
1685
  storage?.removeItem(options.key);
1248
1686
  } catch {
1249
1687
  }
1250
1688
  },
1251
1689
  dispose: () => {
1252
- cancelPending();
1690
+ writes.flush();
1253
1691
  unsubscribe();
1254
1692
  }
1255
1693
  });
@@ -1261,17 +1699,31 @@ var usePersistForm = (form, options) => {
1261
1699
  latestOptions.current = options;
1262
1700
  });
1263
1701
  const key = options.key;
1702
+ const hasStorage = options.storage !== void 0;
1703
+ const storageProxy = useMemo(
1704
+ () => ({
1705
+ getItem: (k) => latestOptions.current.storage?.getItem(k) ?? null,
1706
+ setItem: (k, v) => latestOptions.current.storage?.setItem(k, v),
1707
+ removeItem: (k) => latestOptions.current.storage?.removeItem(k)
1708
+ }),
1709
+ []
1710
+ );
1264
1711
  useEffect(() => {
1265
- const handle = persistForm(form, latestOptions.current);
1712
+ const options2 = latestOptions.current;
1713
+ const handle = persistForm(form, {
1714
+ ...options2,
1715
+ ...options2.storage === void 0 ? {} : { storage: storageProxy }
1716
+ });
1266
1717
  inner.current = handle;
1267
1718
  return () => {
1268
1719
  inner.current = null;
1269
1720
  handle.dispose();
1270
1721
  };
1271
- }, [form, key]);
1722
+ }, [form, key, hasStorage, storageProxy]);
1272
1723
  return useMemo(
1273
1724
  () => ({
1274
1725
  load: () => inner.current?.load() ?? false,
1726
+ flush: () => inner.current?.flush(),
1275
1727
  clear: () => inner.current?.clear(),
1276
1728
  // dispose stops THIS component's watching early; the effect cleanup
1277
1729
  // still runs its own dispose harmlessly (persistForm's dispose is
@@ -1305,9 +1757,12 @@ var useForm = (schema, options) => {
1305
1757
  var hasFieldError = (error) => error !== void 0 && error.length > 0;
1306
1758
  var ariaInvalid = (field) => hasFieldError(field.error) ? true : void 0;
1307
1759
  var numberToInputText = (value) => value === void 0 || value === null || Number.isNaN(value) ? "" : String(value);
1760
+ var DECIMAL_TEXT = /^[+-]?(\d+\.?\d*|\.\d+)([eE][+-]?\d+)?$/;
1308
1761
  var parseNumberText = (text) => {
1309
- if (text.trim() === "") return { kind: "empty" };
1310
- const parsed = Number(text);
1762
+ const trimmed = text.trim();
1763
+ if (trimmed === "") return { kind: "empty" };
1764
+ if (!DECIMAL_TEXT.test(trimmed)) return { kind: "invalid" };
1765
+ const parsed = Number(trimmed);
1311
1766
  return Number.isFinite(parsed) ? { kind: "number", value: parsed } : { kind: "invalid" };
1312
1767
  };
1313
1768
  var textInputProps = (field) => ({
@@ -1335,11 +1790,17 @@ var numberInputProps = (field) => ({
1335
1790
  onBlur: field.onBlur
1336
1791
  });
1337
1792
  var dateToInputText = (value) => {
1338
- if (value === void 0 || value === null || Number.isNaN(value.getTime())) {
1339
- return "";
1793
+ if (value === void 0 || value === null) return "";
1794
+ if (typeof value === "string") {
1795
+ const text = value;
1796
+ const parsed = parseDateText(text);
1797
+ if (parsed.kind === "date") return dateToInputText(parsed.value);
1798
+ if (/^\d{4}-\d{2}-\d{2}$/.test(text.trim())) return "";
1340
1799
  }
1800
+ const date = value instanceof Date ? value : new Date(value);
1801
+ if (Number.isNaN(date.getTime())) return "";
1341
1802
  const pad = (part) => String(part).padStart(2, "0");
1342
- return `${String(value.getFullYear()).padStart(4, "0")}-${pad(value.getMonth() + 1)}-${pad(value.getDate())}`;
1803
+ return `${String(date.getFullYear()).padStart(4, "0")}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`;
1343
1804
  };
1344
1805
  var parseDateText = (text) => {
1345
1806
  if (text.trim() === "") return { kind: "empty" };
@@ -1419,7 +1880,8 @@ var useMaskedInput = (field, options) => {
1419
1880
  field.setValue(parsed.value);
1420
1881
  return;
1421
1882
  case "empty": {
1422
- const empty = field.clearable || typeof field.initialValue !== "string" ? field.emptyValue : "";
1883
+ const isStringField = typeof field.initialValue === "string" || typeof field.value === "string";
1884
+ const empty = field.clearable || !isStringField ? field.emptyValue : "";
1423
1885
  setEdit({ raw: text, pushed: empty });
1424
1886
  field.setValue(empty);
1425
1887
  return;
@@ -1435,6 +1897,15 @@ var useMaskedInput = (field, options) => {
1435
1897
  }
1436
1898
  };
1437
1899
  };
1900
+ var settleOnLiveValues = async (form, fields, round = 0) => {
1901
+ const before = fields.map((path) => form.getField(path));
1902
+ const result = await form.validateFieldsAsync(fields);
1903
+ const changed = fields.some(
1904
+ (path, i) => !Object.is(form.getField(path), before[i])
1905
+ );
1906
+ if (!changed) return result;
1907
+ return round < 3 ? settleOnLiveValues(form, fields, round + 1) : null;
1908
+ };
1438
1909
  var useFormSteps = (form, steps) => {
1439
1910
  const [step, setStep] = useState(0);
1440
1911
  const [visited, setVisited] = useState(
@@ -1448,7 +1919,8 @@ var useFormSteps = (form, steps) => {
1448
1919
  async (index) => {
1449
1920
  const definition = latest.current[index];
1450
1921
  if (definition === void 0) return true;
1451
- const result = await form.validateFieldsAsync(definition.fields);
1922
+ const result = await settleOnLiveValues(form, definition.fields);
1923
+ if (result === null) return false;
1452
1924
  if (result.kind === "invalid") {
1453
1925
  Object.keys(result.errors).forEach((key) => {
1454
1926
  form.setTouched(key, true);
@@ -1460,45 +1932,49 @@ var useFormSteps = (form, steps) => {
1460
1932
  [form]
1461
1933
  );
1462
1934
  const currentStep = useRef(step);
1463
- useEffect(() => {
1464
- currentStep.current = step;
1465
- });
1935
+ const navigate = useCallback((target) => {
1936
+ currentStep.current = target;
1937
+ setStep(target);
1938
+ }, []);
1466
1939
  const next = useCallback(async () => {
1467
- const ok = await validateStep(step);
1940
+ const from = currentStep.current;
1941
+ const ok = await validateStep(from);
1468
1942
  if (!ok) return false;
1469
- if (currentStep.current !== step) return false;
1470
- const target = Math.min(step + 1, latest.current.length - 1);
1471
- setStep(target);
1943
+ if (currentStep.current !== from) return false;
1944
+ const target = Math.max(0, Math.min(from + 1, latest.current.length - 1));
1945
+ navigate(target);
1472
1946
  setVisited((prev) => /* @__PURE__ */ new Set([...prev, target]));
1473
1947
  return true;
1474
- }, [validateStep, step]);
1948
+ }, [validateStep, navigate]);
1475
1949
  const back = useCallback(() => {
1476
- setStep((prev) => Math.max(prev - 1, 0));
1477
- }, []);
1950
+ navigate(Math.max(currentStep.current - 1, 0));
1951
+ }, [navigate]);
1478
1952
  const goTo = useCallback(
1479
1953
  async (target) => {
1480
1954
  const count = latest.current.length;
1481
- const clamped = Math.max(0, Math.min(target, count - 1));
1482
- if (clamped <= step) {
1483
- setStep(clamped);
1955
+ if (!Number.isFinite(target)) return currentStep.current;
1956
+ const from = currentStep.current;
1957
+ const clamped = Math.max(0, Math.min(Math.trunc(target), count - 1));
1958
+ if (clamped <= from) {
1959
+ navigate(clamped);
1484
1960
  return clamped;
1485
1961
  }
1486
- const walk = async (from) => {
1487
- if (from >= clamped) return clamped;
1488
- return await validateStep(from) ? walk(from + 1) : from;
1962
+ const walk = async (at) => {
1963
+ if (at >= clamped) return clamped;
1964
+ return await validateStep(at) ? walk(at + 1) : at;
1489
1965
  };
1490
- const landed = await walk(step);
1491
- if (currentStep.current !== step) return currentStep.current;
1492
- setStep(landed);
1966
+ const landed = await walk(from);
1967
+ if (currentStep.current !== from) return currentStep.current;
1968
+ navigate(landed);
1493
1969
  setVisited(
1494
1970
  (prev) => /* @__PURE__ */ new Set([
1495
1971
  ...prev,
1496
- ...Array.from({ length: landed - step + 1 }, (_, i) => step + i)
1972
+ ...Array.from({ length: landed - from + 1 }, (_, i) => from + i)
1497
1973
  ])
1498
1974
  );
1499
1975
  return landed;
1500
1976
  },
1501
- [validateStep, step]
1977
+ [validateStep, navigate]
1502
1978
  );
1503
1979
  const errorFlags = useFormSelectorShallow(
1504
1980
  form,
@@ -1551,8 +2027,7 @@ var useFormAction = (form, onValid, options) => {
1551
2027
  const { onValid: handler, options: opts } = latest.current;
1552
2028
  const result = await form.submit(
1553
2029
  (data) => handler(data, formData),
1554
- opts?.onInvalid,
1555
- opts?.onError === void 0 ? void 0 : { onError: opts.onError }
2030
+ opts ?? {}
1556
2031
  );
1557
2032
  warnUnobserved(result, opts, "useFormAction");
1558
2033
  },
@@ -1568,8 +2043,7 @@ var useFormActionState = (form, action, initialState, options) => {
1568
2043
  async (data) => {
1569
2044
  nextRef.current = await action(prev, data, formData);
1570
2045
  },
1571
- options?.onInvalid,
1572
- options?.onError === void 0 ? void 0 : { onError: options.onError }
2046
+ options ?? {}
1573
2047
  );
1574
2048
  warnUnobserved(result, options, "useFormActionState");
1575
2049
  return nextRef.current;
@@ -1593,6 +2067,7 @@ var createFormContext = () => {
1593
2067
  return { Provider, useFormContext };
1594
2068
  };
1595
2069
  function useField(form, pathArg, options) {
2070
+ const formSchema = form.schema;
1596
2071
  const slice = useStore(
1597
2072
  form.store,
1598
2073
  useShallow((state) => {
@@ -1608,25 +2083,26 @@ function useField(form, pathArg, options) {
1608
2083
  error: entryAt(state.errors, p),
1609
2084
  touched: flagAt(state.touched, p),
1610
2085
  dirty: isFieldDirty(value, initialValue2),
1611
- isValidating: flagAt(state.isValidating, p)
2086
+ isValidating: flagAt(state.isValidating, p),
2087
+ // Resolved inside the selector so a discriminant change re-reads
2088
+ // the ACTIVE variant's wrapper for a key the variants share; the
2089
+ // schema node is a stable reference, so the shallow compare holds.
2090
+ schema: formSchema === void 0 ? null : fieldSchemaForIntrospection(formSchema, p, state.values)
1612
2091
  };
1613
2092
  })
1614
2093
  );
1615
2094
  const path = slice.path;
1616
- const formSchema = form.schema;
2095
+ const sub = slice.schema;
1617
2096
  const initialValue = slice.initialValue;
1618
2097
  const { emptyValue, clearable } = useMemo(() => {
1619
- if (formSchema !== void 0) {
1620
- const sub = fieldSchemaForIntrospection(formSchema, path);
1621
- if (sub !== null) {
1622
- return {
1623
- emptyValue: emptyValueForSchema(sub),
1624
- clearable: isClearableSchema(sub)
1625
- };
1626
- }
2098
+ if (sub !== null) {
2099
+ return {
2100
+ emptyValue: emptyValueForSchema(sub),
2101
+ clearable: isClearableSchema(sub)
2102
+ };
1627
2103
  }
1628
2104
  return initialValue === null ? { emptyValue: null, clearable: true } : { emptyValue: void 0, clearable: false };
1629
- }, [formSchema, path, initialValue]);
2105
+ }, [sub, initialValue]);
1630
2106
  const debounceMs = options?.debounceMs;
1631
2107
  const debouncerRef = useRef(null);
1632
2108
  useEffect(() => {
@@ -1635,6 +2111,12 @@ function useField(form, pathArg, options) {
1635
2111
  debouncerRef.current = null;
1636
2112
  };
1637
2113
  }, [form, path, debounceMs]);
2114
+ const validateNow = useCallback(() => {
2115
+ const result = form.validateField(path);
2116
+ if (result.kind === "pending") {
2117
+ void result.promise.catch(reportBackgroundFailure);
2118
+ }
2119
+ }, [form, path]);
1638
2120
  const triggerValidate = useCallback(() => {
1639
2121
  if (debounceMs !== void 0 && debounceMs > 0) {
1640
2122
  const debouncer = debouncerRef.current ?? createDebouncer(debounceMs);
@@ -1646,12 +2128,12 @@ function useField(form, pathArg, options) {
1646
2128
  const rebased = state.values !== scheduledValues && state.values === state.initialValues;
1647
2129
  if (rebased) return;
1648
2130
  if (!Object.is(getAtPath(state.values, path), scheduled)) return;
1649
- void form.validateFieldAsync(path);
2131
+ void form.validateFieldAsync(path).catch(reportBackgroundFailure);
1650
2132
  });
1651
2133
  return;
1652
2134
  }
1653
- form.validateField(path);
1654
- }, [form, path, debounceMs]);
2135
+ validateNow();
2136
+ }, [form, path, debounceMs, validateNow]);
1655
2137
  const setValue = useCallback(
1656
2138
  (value) => {
1657
2139
  const before = form.store.getState();
@@ -1700,9 +2182,10 @@ function useField(form, pathArg, options) {
1700
2182
  state.submitCount > 0,
1701
2183
  true
1702
2184
  )) {
1703
- triggerValidate();
2185
+ debouncerRef.current?.cancel();
2186
+ validateNow();
1704
2187
  }
1705
- }, [form, path, triggerValidate]);
2188
+ }, [form, path, validateNow]);
1706
2189
  return useMemo(
1707
2190
  () => ({
1708
2191
  path: slice.path,
@@ -1729,10 +2212,11 @@ function useField(form, pathArg, options) {
1729
2212
  }
1730
2213
 
1731
2214
  // src/react/useVariantField.ts
1732
- function useVariantField(form, unionPath, field) {
2215
+ function useVariantField(form, unionPath, field, options) {
1733
2216
  return useField(
1734
2217
  form,
1735
- `${unionPath}.${field}`
2218
+ `${unionPath}.${field}`,
2219
+ options
1736
2220
  );
1737
2221
  }
1738
2222
  var EMPTY_ITEMS = [];
@@ -1895,6 +2379,12 @@ function useFieldArray(form, pathArg) {
1895
2379
  const items = slice.items;
1896
2380
  const error = slice.error;
1897
2381
  const ids = deriveIds(form.store, path, items).ids;
2382
+ const validateNow = useCallback(() => {
2383
+ const result = form.validateField?.(path);
2384
+ if (result?.kind === "pending") {
2385
+ void result.promise.catch(reportBackgroundFailure);
2386
+ }
2387
+ }, [form, path]);
1898
2388
  const revalidate = useCallback(() => {
1899
2389
  const state = form.store.getState();
1900
2390
  if (shouldValidateOn(
@@ -1904,9 +2394,9 @@ function useFieldArray(form, pathArg) {
1904
2394
  state.submitCount > 0,
1905
2395
  flagAt(state.touched, path)
1906
2396
  )) {
1907
- form.validateField?.(path);
2397
+ validateNow();
1908
2398
  }
1909
- }, [form, path]);
2399
+ }, [form, path, validateNow]);
1910
2400
  const push = useCallback(
1911
2401
  (item) => {
1912
2402
  form.arrayPush(path, item);
@@ -1971,13 +2461,17 @@ function useFieldArray(form, pathArg) {
1971
2461
  state.submitCount > 0,
1972
2462
  true
1973
2463
  )) {
1974
- form.validateField?.(path);
2464
+ validateNow();
1975
2465
  }
1976
- }, [form, path]);
2466
+ }, [form, path, validateNow]);
1977
2467
  const validate = useCallback(
1978
2468
  () => form.validateField?.(path) ?? { kind: "valid" },
1979
2469
  [form, path]
1980
2470
  );
2471
+ const validateAsync2 = useCallback(
2472
+ () => form.validateFieldAsync?.(path) ?? Promise.resolve({ kind: "valid" }),
2473
+ [form, path]
2474
+ );
1981
2475
  const replace = useCallback(
1982
2476
  (next) => {
1983
2477
  form.setValue?.(path, next);
@@ -1987,7 +2481,15 @@ function useFieldArray(form, pathArg) {
1987
2481
  );
1988
2482
  const update = useCallback(
1989
2483
  (index, item) => {
1990
- form.setValue?.(`${path}.${index}`, item);
2484
+ const current = getAtPath(form.store.getState().values, path);
2485
+ const length = Array.isArray(current) ? current.length : 0;
2486
+ if (!Number.isInteger(index) || index < 0 || index >= length) {
2487
+ console.warn(
2488
+ `[formstand] array op on "${path}" with an out-of-range or non-integer index (length ${String(length)}). Operation skipped.`
2489
+ );
2490
+ return;
2491
+ }
2492
+ form.setValue?.(`${path}.${String(index)}`, item);
1991
2493
  revalidate();
1992
2494
  },
1993
2495
  [form, path, revalidate]
@@ -2019,6 +2521,7 @@ function useFieldArray(form, pathArg) {
2019
2521
  setTouched,
2020
2522
  onBlur,
2021
2523
  validate,
2524
+ validateAsync: validateAsync2,
2022
2525
  push,
2023
2526
  remove,
2024
2527
  insert,
@@ -2042,6 +2545,7 @@ function useFieldArray(form, pathArg) {
2042
2545
  setTouched,
2043
2546
  onBlur,
2044
2547
  validate,
2548
+ validateAsync2,
2045
2549
  push,
2046
2550
  remove,
2047
2551
  insert,
@@ -2069,29 +2573,41 @@ var useFormError = (form) => useStore(
2069
2573
  function useFormValues(form) {
2070
2574
  return useStore(form.store, (state) => state.values);
2071
2575
  }
2072
-
2073
- // src/react/useFields.ts
2074
- function useFields(form, paths) {
2576
+ function useFields(form, paths, options) {
2577
+ const [arity] = useState(paths.length);
2578
+ if (paths.length !== arity) {
2579
+ throw new Error(
2580
+ `[formstand] useFields: the paths tuple changed length (${String(arity)} to ${String(paths.length)}); its length and order must be stable across renders, so pass a literal or a module-level constant.`
2581
+ );
2582
+ }
2075
2583
  const fields = paths.map(
2076
2584
  (path) => (
2077
2585
  // eslint-disable-next-line react-hooks/rules-of-hooks -- a fixed hook count by the stable-arity contract above; each element must be a REAL UseFieldReturn so it spreads into the prop builders
2078
- useField(form, path)
2586
+ useField(
2587
+ form,
2588
+ path,
2589
+ options
2590
+ )
2079
2591
  )
2080
2592
  );
2081
- const merged = [...new Set(fields.flatMap((field) => field.error ?? []))];
2082
- return {
2083
- fields,
2084
- error: merged.length > 0 ? merged : void 0,
2085
- firstError: merged[0],
2086
- touched: fields.some((field) => field.touched),
2087
- dirty: fields.some((field) => field.dirty),
2088
- isValidating: fields.some((field) => field.isValidating),
2089
- onBlur: () => {
2090
- fields.forEach((field) => {
2091
- field.onBlur();
2092
- });
2093
- }
2094
- };
2593
+ return useMemo(() => {
2594
+ const merged = [
2595
+ ...new Set(fields.flatMap((field) => field.error ?? []))
2596
+ ];
2597
+ return {
2598
+ fields,
2599
+ error: merged.length > 0 ? merged : void 0,
2600
+ firstError: merged[0],
2601
+ touched: fields.some((field) => field.touched),
2602
+ dirty: fields.some((field) => field.dirty),
2603
+ isValidating: fields.some((field) => field.isValidating),
2604
+ onBlur: () => {
2605
+ fields.forEach((field) => {
2606
+ field.onBlur();
2607
+ });
2608
+ }
2609
+ };
2610
+ }, fields);
2095
2611
  }
2096
2612
  function useIsDirty(form, path) {
2097
2613
  return useStore(
@@ -2134,10 +2650,11 @@ var capitalize = (name) => name.length === 0 ? name : name.charAt(0).toUpperCase
2134
2650
  var createFormHooks = (form, name) => {
2135
2651
  const structural = form;
2136
2652
  const useBoundField = (path, options) => useField(structural, path, options);
2137
- const useBoundVariantField = (unionPath, field) => useVariantField(
2653
+ const useBoundVariantField = (unionPath, field, options) => useVariantField(
2138
2654
  structural,
2139
2655
  unionPath,
2140
- field
2656
+ field,
2657
+ options
2141
2658
  );
2142
2659
  const useBoundFieldArray = (path) => useFieldArray(structural, path);
2143
2660
  const useBoundVariantFieldArray = (unionPath, field) => useVariantFieldArray(
@@ -2153,12 +2670,16 @@ var createFormHooks = (form, name) => {
2153
2670
  const useBoundIsValidating = (path) => useIsValidating(structural, path);
2154
2671
  const useBoundIsSubmitting = () => useIsSubmitting(structural);
2155
2672
  const useBoundValues = () => useFormValues(structural);
2156
- const useBoundFields = (paths) => useFields(
2673
+ const useBoundFields = (paths, options) => useFields(
2157
2674
  structural,
2158
- paths
2675
+ paths,
2676
+ options
2159
2677
  );
2160
2678
  const useBoundSubmitCount = () => useSubmitCount(structural);
2161
2679
  const useBoundSteps = (steps) => useFormSteps(form, steps);
2680
+ const useBoundAction = (onValid, options) => useFormAction(form, onValid, options);
2681
+ const useBoundActionState = (action, initialState, options) => useFormActionState(form, action, initialState, options);
2682
+ const useBoundPersist = (options) => usePersistForm(form, options);
2162
2683
  const prefix = capitalize(name ?? "");
2163
2684
  return {
2164
2685
  [`use${prefix}Field`]: useBoundField,
@@ -2175,7 +2696,10 @@ var createFormHooks = (form, name) => {
2175
2696
  [`use${prefix}SubmitCount`]: useBoundSubmitCount,
2176
2697
  [`use${prefix}Values`]: useBoundValues,
2177
2698
  [`use${prefix}Fields`]: useBoundFields,
2178
- [`use${prefix}Steps`]: useBoundSteps
2699
+ [`use${prefix}Steps`]: useBoundSteps,
2700
+ [`use${prefix}Action`]: useBoundAction,
2701
+ [`use${prefix}ActionState`]: useBoundActionState,
2702
+ [`use${prefix}Persist`]: useBoundPersist
2179
2703
  };
2180
2704
  };
2181
2705
  var useFieldA11y = (error) => {
@@ -2199,7 +2723,7 @@ var FieldShell = ({
2199
2723
  wrapLabel,
2200
2724
  children
2201
2725
  }) => /* @__PURE__ */ jsxs("div", { className: "zf-field", children: [
2202
- wrapLabel === true ? /* @__PURE__ */ jsxs("label", { htmlFor: id, className: "zf-label", children: [
2726
+ wrapLabel === true && label !== void 0 ? /* @__PURE__ */ jsxs("label", { htmlFor: id, className: "zf-label", children: [
2203
2727
  children,
2204
2728
  " ",
2205
2729
  label
@@ -2470,6 +2994,6 @@ var focusField = (path, root) => {
2470
2994
  return path === "" ? (root !== void 0 || document.querySelectorAll("form").length <= 1) && focusFirstOf(namedControls(scope).filter(isFocusCandidate)) : focusFirstOf(pathCandidates(scope, [path]).filter(isFocusCandidate));
2471
2995
  };
2472
2996
 
2473
- export { CheckboxField, DateField, NumberField, SelectField, TextField, checkboxProps, createForm, createFormContext, createFormHooks, dateInputProps, dateToInputText, emptyValueForSchema, flattenIssues, focusField, focusFirstError, hasFieldError, isAsyncRequiredError, isClearableSchema, numberInputProps, numberToInputText, parseDateText, parseNumberText, persistForm, selectProps, textInputProps, useField, useFieldArray, useFields, useForm, useFormAction, useFormActionState, useFormError, useFormSteps, useFormValues, useIsDirty, useIsSubmitting, useIsValid, useIsValidating, useMaskedInput, useNumberInput, usePersistForm, useSubmitCount, useVariantField, useVariantFieldArray, validateAsync, validateSync };
2997
+ export { CheckboxField, DateField, NumberField, SelectField, TextField, checkboxProps, createForm, createFormContext, createFormHooks, dateInputProps, dateToInputText, emptyValueForSchema, flattenIssues, focusField, focusFirstError, getAtPath, hasFieldError, isAsyncRequiredError, isClearableSchema, numberInputProps, numberToInputText, parseDateText, parseNumberText, parsePath, persistForm, selectProps, setAtPath, textInputProps, useField, useFieldArray, useFields, useForm, useFormAction, useFormActionState, useFormError, useFormSteps, useFormValues, useIsDirty, useIsSubmitting, useIsValid, useIsValidating, useMaskedInput, useNumberInput, usePersistForm, useSubmitCount, useVariantField, useVariantFieldArray, validateAsync, validateSync };
2474
2998
  //# sourceMappingURL=index.js.map
2475
2999
  //# sourceMappingURL=index.js.map