@voila.dev/effect-form 0.27.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +7 -0
  3. package/dist/Field.d.ts +30 -0
  4. package/dist/Field.d.ts.map +1 -0
  5. package/dist/Field.js +86 -0
  6. package/dist/Field.js.map +1 -0
  7. package/dist/FieldState.d.ts +21 -0
  8. package/dist/FieldState.d.ts.map +1 -0
  9. package/dist/FieldState.js +2 -0
  10. package/dist/FieldState.js.map +1 -0
  11. package/dist/FormAtoms.d.ts +108 -0
  12. package/dist/FormAtoms.d.ts.map +1 -0
  13. package/dist/FormAtoms.js +620 -0
  14. package/dist/FormAtoms.js.map +1 -0
  15. package/dist/FormBuilder.d.ts +74 -0
  16. package/dist/FormBuilder.d.ts.map +1 -0
  17. package/dist/FormBuilder.js +86 -0
  18. package/dist/FormBuilder.js.map +1 -0
  19. package/dist/Mode.d.ts +34 -0
  20. package/dist/Mode.d.ts.map +1 -0
  21. package/dist/Mode.js +19 -0
  22. package/dist/Mode.js.map +1 -0
  23. package/dist/Path.d.ts +6 -0
  24. package/dist/Path.d.ts.map +1 -0
  25. package/dist/Path.js +69 -0
  26. package/dist/Path.js.map +1 -0
  27. package/dist/Validation.d.ts +11 -0
  28. package/dist/Validation.d.ts.map +1 -0
  29. package/dist/Validation.js +128 -0
  30. package/dist/Validation.js.map +1 -0
  31. package/dist/index.d.ts +8 -0
  32. package/dist/index.d.ts.map +1 -0
  33. package/dist/index.js +8 -0
  34. package/dist/index.js.map +1 -0
  35. package/dist/internal/dirty.d.ts +3 -0
  36. package/dist/internal/dirty.d.ts.map +1 -0
  37. package/dist/internal/dirty.js +89 -0
  38. package/dist/internal/dirty.js.map +1 -0
  39. package/package.json +43 -0
  40. package/src/Field.ts +149 -0
  41. package/src/FieldState.ts +23 -0
  42. package/src/FormAtoms.ts +1202 -0
  43. package/src/FormBuilder.ts +235 -0
  44. package/src/Mode.ts +63 -0
  45. package/src/Path.ts +76 -0
  46. package/src/Validation.ts +167 -0
  47. package/src/index.ts +13 -0
  48. package/src/internal/dirty.ts +113 -0
@@ -0,0 +1,1202 @@
1
+ import * as Duration from "effect/Duration";
2
+ import * as Effect from "effect/Effect";
3
+ import { pipe } from "effect/Function";
4
+ import * as Option from "effect/Option";
5
+ import * as Schema from "effect/Schema";
6
+ import * as AsyncResult from "effect/unstable/reactivity/AsyncResult";
7
+ import * as Atom from "effect/unstable/reactivity/Atom";
8
+ import * as Field from "./Field.ts";
9
+ import * as FormBuilder from "./FormBuilder.ts";
10
+ import {
11
+ recalculateDirtyFieldsForArray,
12
+ recalculateDirtySubtree,
13
+ } from "./internal/dirty.ts";
14
+ import * as Mode from "./Mode.ts";
15
+ import { getNestedValue, isPathOrParentDirty, setNestedValue } from "./Path.ts";
16
+ import * as Validation from "./Validation.ts";
17
+
18
+ export interface FieldAtoms {
19
+ readonly valueAtom: Atom.Writable<unknown, unknown>;
20
+ readonly initialValueAtom: Atom.Atom<unknown>;
21
+ readonly touchedAtom: Atom.Writable<boolean, boolean>;
22
+ readonly errorAtom: Atom.Atom<Option.Option<Validation.ErrorEntry>>;
23
+ readonly isDirtyAtom: Atom.Atom<boolean>;
24
+ readonly validationAtom: Atom.AtomResultFn<unknown, void, Schema.SchemaError>;
25
+ readonly displayErrorAtom: Atom.Atom<Option.Option<string>>;
26
+ readonly fieldValidationCountAtom: Atom.Writable<number, number>;
27
+ readonly shouldValidateAtom: Atom.Atom<boolean>;
28
+ readonly triggerValidationAtom: Atom.Atom<void>;
29
+ }
30
+
31
+ export interface PublicFieldAtoms<E> {
32
+ readonly value: Atom.Atom<Option.Option<E>>;
33
+ readonly error: Atom.Atom<Option.Option<string>>;
34
+ readonly isDirty: Atom.Atom<boolean>;
35
+ readonly isTouched: Atom.Atom<boolean>;
36
+ readonly isValidating: Atom.Atom<boolean>;
37
+ readonly setValue: Atom.Writable<void, E | ((prev: E) => E)>;
38
+ readonly setTouched: Atom.Writable<void, boolean>;
39
+ readonly validate: Atom.Writable<void, void>;
40
+ }
41
+
42
+ export interface FormAtomsConfig<
43
+ TFields extends Field.FieldsRecord,
44
+ R,
45
+ A,
46
+ E,
47
+ SubmitArgs = void,
48
+ > {
49
+ readonly runtime: Atom.AtomRuntime<R, any>;
50
+ readonly formBuilder: FormBuilder.FormBuilder<TFields, R>;
51
+ readonly mode?: Mode.FormMode;
52
+ readonly reactivityKeys?:
53
+ | ReadonlyArray<unknown>
54
+ | Readonly<Record<string, ReadonlyArray<unknown>>>
55
+ | undefined;
56
+ readonly onSubmit: (
57
+ args: SubmitArgs,
58
+ ctx: {
59
+ readonly decoded: Field.DecodedFromFields<TFields>;
60
+ readonly encoded: Field.EncodedFromFields<TFields>;
61
+ readonly get: Atom.FnContext;
62
+ },
63
+ ) => A | Effect.Effect<A, E, R>;
64
+ }
65
+
66
+ export type FieldRefs<TFields extends Field.FieldsRecord> = {
67
+ readonly [K in keyof TFields]: TFields[K] extends Field.FieldDef<any, infer S>
68
+ ? FormBuilder.FieldRef<Schema.Codec.Encoded<S>>
69
+ : TFields[K] extends Field.ArrayFieldDef<any, infer S>
70
+ ? FormBuilder.FieldRef<ReadonlyArray<Schema.Codec.Encoded<S>>>
71
+ : never;
72
+ };
73
+
74
+ export interface FormAtoms<
75
+ TFields extends Field.FieldsRecord,
76
+ R,
77
+ A = void,
78
+ E = never,
79
+ SubmitArgs = void,
80
+ > {
81
+ readonly stateAtom: Atom.Writable<
82
+ Option.Option<FormBuilder.FormState<TFields>>,
83
+ Option.Option<FormBuilder.FormState<TFields>>
84
+ >;
85
+ readonly errorsAtom: Atom.Writable<
86
+ Map<string, Validation.ErrorEntry>,
87
+ Map<string, Validation.ErrorEntry>
88
+ >;
89
+ readonly rootErrorAtom: Atom.Atom<Option.Option<string>>;
90
+ readonly valuesAtom: Atom.Atom<
91
+ Option.Option<Field.EncodedFromFields<TFields>>
92
+ >;
93
+ readonly dirtyFieldsAtom: Atom.Atom<ReadonlySet<string>>;
94
+ readonly isDirtyAtom: Atom.Atom<boolean>;
95
+ readonly submitCountAtom: Atom.Atom<number>;
96
+ readonly validationCountAtom: Atom.Atom<number>;
97
+ readonly lastSubmittedValuesAtom: Atom.Atom<
98
+ Option.Option<FormBuilder.SubmittedValues<TFields>>
99
+ >;
100
+ readonly changedSinceSubmitFieldsAtom: Atom.Atom<ReadonlySet<string>>;
101
+ readonly hasChangedSinceSubmitAtom: Atom.Atom<boolean>;
102
+
103
+ readonly submitAtom: Atom.AtomResultFn<SubmitArgs, A, E | Schema.SchemaError>;
104
+ readonly validateAtom: Atom.AtomResultFn<void, void, never>;
105
+
106
+ readonly combinedSchema: Schema.Codec<
107
+ Field.DecodedFromFields<TFields>,
108
+ Field.EncodedFromFields<TFields>,
109
+ R
110
+ >;
111
+
112
+ readonly fieldRefs: FieldRefs<TFields>;
113
+
114
+ readonly getOrCreateValidationAtom: (
115
+ fieldPath: string,
116
+ schema: Schema.Top,
117
+ ) => Atom.AtomResultFn<unknown, void, Schema.SchemaError>;
118
+
119
+ readonly getOrCreateFieldAtoms: (
120
+ fieldPath: string,
121
+ schema: Schema.Top,
122
+ ) => FieldAtoms;
123
+
124
+ readonly resetValidationAtoms: (ctx: {
125
+ set: <R, W>(atom: Atom.Writable<R, W>, value: W) => void;
126
+ }) => void;
127
+
128
+ readonly operations: FormOperations<TFields>;
129
+
130
+ readonly resetAtom: Atom.Writable<void, void>;
131
+ readonly revertToLastSubmitAtom: Atom.Writable<void, void>;
132
+ readonly setValuesAtom: Atom.Writable<Field.EncodedFromFields<TFields>>;
133
+
134
+ readonly getFieldAtoms: <S>(
135
+ field: FormBuilder.FieldRef<S>,
136
+ ) => PublicFieldAtoms<S>;
137
+
138
+ /**
139
+ * Root anchor atom for the form's dependency graph.
140
+ * Mount this atom to keep all form state alive even when field components unmount.
141
+ *
142
+ * Useful for:
143
+ * - Multi-step wizards where steps unmount but state should persist
144
+ * - Conditional fields (toggles) where state should survive visibility changes
145
+ *
146
+ * @example
147
+ * ```tsx
148
+ * // Keep form state alive at wizard root level
149
+ * function Wizard() {
150
+ * useAtomMount(step1Form.mount)
151
+ * useAtomMount(step2Form.mount)
152
+ * return currentStep === 1 ? <Step1 /> : <Step2 />
153
+ * }
154
+ * ```
155
+ */
156
+ readonly autoSubmitAtom: Atom.Atom<void>;
157
+ readonly onBlurSubmitAtom: Atom.Writable<void, void>;
158
+
159
+ readonly mountAtom: Atom.Atom<void>;
160
+
161
+ readonly keepAliveActiveAtom: Atom.Writable<boolean, boolean>;
162
+ }
163
+
164
+ export interface FormOperations<TFields extends Field.FieldsRecord> {
165
+ readonly createInitialState: (
166
+ defaultValues: Field.EncodedFromFields<TFields>,
167
+ ) => FormBuilder.FormState<TFields>;
168
+
169
+ readonly createResetState: (
170
+ state: FormBuilder.FormState<TFields>,
171
+ ) => FormBuilder.FormState<TFields>;
172
+
173
+ readonly createSubmitState: (
174
+ state: FormBuilder.FormState<TFields>,
175
+ ) => FormBuilder.FormState<TFields>;
176
+
177
+ readonly setFieldValue: (
178
+ state: FormBuilder.FormState<TFields>,
179
+ fieldPath: string,
180
+ value: unknown,
181
+ ) => FormBuilder.FormState<TFields>;
182
+
183
+ readonly setFormValues: (
184
+ state: FormBuilder.FormState<TFields>,
185
+ values: Field.EncodedFromFields<TFields>,
186
+ ) => FormBuilder.FormState<TFields>;
187
+
188
+ readonly setFieldTouched: (
189
+ state: FormBuilder.FormState<TFields>,
190
+ fieldPath: string,
191
+ touched: boolean,
192
+ ) => FormBuilder.FormState<TFields>;
193
+
194
+ readonly appendArrayItem: (
195
+ state: FormBuilder.FormState<TFields>,
196
+ arrayPath: string,
197
+ itemSchema: Schema.Top,
198
+ value?: unknown,
199
+ ) => FormBuilder.FormState<TFields>;
200
+
201
+ readonly removeArrayItem: (
202
+ state: FormBuilder.FormState<TFields>,
203
+ arrayPath: string,
204
+ index: number,
205
+ ) => FormBuilder.FormState<TFields>;
206
+
207
+ readonly swapArrayItems: (
208
+ state: FormBuilder.FormState<TFields>,
209
+ arrayPath: string,
210
+ indexA: number,
211
+ indexB: number,
212
+ ) => FormBuilder.FormState<TFields>;
213
+
214
+ readonly moveArrayItem: (
215
+ state: FormBuilder.FormState<TFields>,
216
+ arrayPath: string,
217
+ fromIndex: number,
218
+ toIndex: number,
219
+ ) => FormBuilder.FormState<TFields>;
220
+
221
+ readonly revertToLastSubmit: (
222
+ state: FormBuilder.FormState<TFields>,
223
+ ) => FormBuilder.FormState<TFields>;
224
+ }
225
+
226
+ const getStateOrThrow = <TFields extends Field.FieldsRecord>(
227
+ state: Option.Option<FormBuilder.FormState<TFields>>,
228
+ fieldPath: string,
229
+ ): FormBuilder.FormState<TFields> => {
230
+ if (Option.isNone(state)) {
231
+ throw new Error(
232
+ `Field "${fieldPath}" was read before the form was initialized. ` +
233
+ "Form state does not exist until initialization: render your fields inside " +
234
+ "<form.Initialize defaultValues={...}> (React/Solid), or set the form state before reading field atoms. " +
235
+ `See the "Basic Form Setup" section of the README.`,
236
+ );
237
+ }
238
+ return state.value;
239
+ };
240
+
241
+ export const make = <
242
+ TFields extends Field.FieldsRecord,
243
+ R,
244
+ A,
245
+ E,
246
+ SubmitArgs = void,
247
+ >(
248
+ config: FormAtomsConfig<TFields, R, A, E, SubmitArgs>,
249
+ ): FormAtoms<TFields, R, A, E, SubmitArgs> => {
250
+ const { formBuilder, runtime } = config;
251
+ const { fields } = formBuilder;
252
+ const parsedMode = Mode.parse(config.mode);
253
+
254
+ // A zero (or absent) debounce means "fire synchronously", so only a strictly
255
+ // positive duration goes through `Atom.debounce`.
256
+ const positiveDebounce = (
257
+ input: Duration.Input | null,
258
+ ): Duration.Input | null =>
259
+ input !== null && Duration.toMillis(Duration.fromInputUnsafe(input)) > 0
260
+ ? input
261
+ : null;
262
+
263
+ const validationDebounce =
264
+ parsedMode.validation === "onChange" && !parsedMode.autoSubmit
265
+ ? positiveDebounce(parsedMode.debounce)
266
+ : null;
267
+ const autoSubmitDebounce = positiveDebounce(parsedMode.debounce);
268
+
269
+ const combinedSchema = FormBuilder.buildSchema(formBuilder);
270
+
271
+ const stateAtom = Atom.make(
272
+ Option.none<FormBuilder.FormState<TFields>>(),
273
+ ).pipe(Atom.setIdleTTL(0));
274
+ const errorsAtom = Atom.make<Map<string, Validation.ErrorEntry>>(
275
+ new Map(),
276
+ ).pipe(Atom.setIdleTTL(0));
277
+
278
+ const rootErrorAtom = Atom.readable((get) => {
279
+ const errors = get(errorsAtom);
280
+ const entry = errors.get("");
281
+ return entry ? Option.some(entry.message) : Option.none<string>();
282
+ }).pipe(Atom.setIdleTTL(0));
283
+
284
+ const valuesAtom = Atom.readable((get) =>
285
+ Option.map(get(stateAtom), (state) => state.values),
286
+ ).pipe(Atom.setIdleTTL(0));
287
+
288
+ const dirtyFieldsAtom = Atom.readable((get) =>
289
+ Option.match(get(stateAtom), {
290
+ onNone: () => new Set<string>(),
291
+ onSome: (state) => state.dirtyFields,
292
+ }),
293
+ ).pipe(Atom.setIdleTTL(0));
294
+
295
+ const isDirtyAtom = Atom.readable((get) =>
296
+ Option.match(get(stateAtom), {
297
+ onNone: () => false,
298
+ onSome: (state) => state.dirtyFields.size > 0,
299
+ }),
300
+ ).pipe(Atom.setIdleTTL(0));
301
+
302
+ const submitCountAtom = Atom.readable((get) =>
303
+ Option.match(get(stateAtom), {
304
+ onNone: () => 0,
305
+ onSome: (state) => state.submitCount,
306
+ }),
307
+ ).pipe(Atom.setIdleTTL(0));
308
+
309
+ const validationCountAtom = Atom.readable((get) =>
310
+ Option.match(get(stateAtom), {
311
+ onNone: () => 0,
312
+ onSome: (state) => state.validationCount,
313
+ }),
314
+ ).pipe(Atom.setIdleTTL(0));
315
+
316
+ const lastSubmittedValuesAtom = Atom.readable((get) =>
317
+ Option.flatMap(get(stateAtom), (state) => state.lastSubmittedValues),
318
+ ).pipe(Atom.setIdleTTL(0));
319
+
320
+ const changedSinceSubmitFieldsAtom = Atom.readable((get) =>
321
+ Option.match(get(stateAtom), {
322
+ onNone: () => new Set<string>(),
323
+ onSome: (state) =>
324
+ Option.match(state.lastSubmittedValues, {
325
+ onNone: () => new Set<string>(),
326
+ onSome: (lastSubmitted) =>
327
+ recalculateDirtySubtree(
328
+ new Set(),
329
+ lastSubmitted.encoded,
330
+ state.values,
331
+ "",
332
+ ),
333
+ }),
334
+ }),
335
+ ).pipe(Atom.setIdleTTL(0));
336
+
337
+ const hasChangedSinceSubmitAtom = Atom.readable((get) =>
338
+ Option.match(get(stateAtom), {
339
+ onNone: () => false,
340
+ onSome: (state) => {
341
+ if (Option.isNone(state.lastSubmittedValues)) return false;
342
+ if (state.values === state.lastSubmittedValues.value.encoded)
343
+ return false;
344
+ return get(changedSinceSubmitFieldsAtom).size > 0;
345
+ },
346
+ }),
347
+ ).pipe(Atom.setIdleTTL(0));
348
+
349
+ const fieldSchemasByKey = new Map<string, Schema.Top>();
350
+ for (const [key, def] of Object.entries(fields)) {
351
+ if (Field.isArrayFieldDef(def)) {
352
+ fieldSchemasByKey.set(key, Schema.Array(def.itemSchema));
353
+ } else if (Field.isFieldDef(def)) {
354
+ fieldSchemasByKey.set(key, def.schema);
355
+ }
356
+ }
357
+
358
+ // Families are keyed by `{ path, schema }` plain objects. `Atom.family` compares
359
+ // keys with structural Hash/Equal, so `path` compares by value while `schema`
360
+ // compares by reference — passing a different schema instance for the same path
361
+ // produces a fresh family entry, preserving the previous schema-identity
362
+ // recreation behavior of the hand-rolled registries.
363
+ interface FieldFamilyKey {
364
+ readonly path: string;
365
+ readonly schema: Schema.Top;
366
+ }
367
+
368
+ // `Atom.family` does not expose iteration, but `resetValidationAtoms` must visit
369
+ // every created entry. These maps record the latest key per path (paths are
370
+ // bounded by the form's field/array-item structure, so no GC concern) and double
371
+ // as the schema-identity record: `family(key)` with a recorded key returns the
372
+ // memoized entry, or harmlessly recreates a fresh one if it was collected.
373
+ const validationKeys = new Map<string, FieldFamilyKey>();
374
+ const fieldAtomsKeys = new Map<string, FieldFamilyKey>();
375
+
376
+ const validationAtomFamily = Atom.family(
377
+ ({
378
+ schema,
379
+ }: FieldFamilyKey): Atom.AtomResultFn<unknown, void, Schema.SchemaError> =>
380
+ runtime
381
+ .fn<unknown>()((value: unknown) =>
382
+ pipe(
383
+ Schema.decodeUnknownEffect(schema)(value) as Effect.Effect<
384
+ unknown,
385
+ Schema.SchemaError,
386
+ R
387
+ >,
388
+ Effect.asVoid,
389
+ ),
390
+ )
391
+ .pipe(Atom.setIdleTTL(0)) as Atom.AtomResultFn<
392
+ unknown,
393
+ void,
394
+ Schema.SchemaError
395
+ >,
396
+ );
397
+
398
+ const getOrCreateValidationAtom = (
399
+ fieldPath: string,
400
+ schema: Schema.Top,
401
+ ): Atom.AtomResultFn<unknown, void, Schema.SchemaError> => {
402
+ const key: FieldFamilyKey = { path: fieldPath, schema };
403
+ validationKeys.set(fieldPath, key);
404
+ return validationAtomFamily(key);
405
+ };
406
+
407
+ const isDirtyAtomFamily = Atom.family(
408
+ (fieldPath: string): Atom.Atom<boolean> =>
409
+ Atom.readable((get) =>
410
+ isPathOrParentDirty(
411
+ Option.match(get(stateAtom), {
412
+ onNone: () => new Set<string>(),
413
+ onSome: (state) => state.dirtyFields,
414
+ }),
415
+ fieldPath,
416
+ ),
417
+ ).pipe(Atom.setIdleTTL(0)),
418
+ );
419
+
420
+ const fieldAtomsFamily = Atom.family(
421
+ ({ path: fieldPath, schema }: FieldFamilyKey): FieldAtoms => {
422
+ const valueAtom = Atom.writable(
423
+ (get) =>
424
+ getNestedValue(
425
+ getStateOrThrow(get(stateAtom), fieldPath).values,
426
+ fieldPath,
427
+ ),
428
+ (ctx, value) => {
429
+ const currentState = getStateOrThrow(ctx.get(stateAtom), fieldPath);
430
+ ctx.set(
431
+ stateAtom,
432
+ Option.some(
433
+ operations.setFieldValue(currentState, fieldPath, value),
434
+ ),
435
+ );
436
+ },
437
+ ).pipe(Atom.setIdleTTL(0));
438
+
439
+ const initialValueAtom = Atom.readable((get) =>
440
+ getNestedValue(
441
+ getStateOrThrow(get(stateAtom), fieldPath).initialValues,
442
+ fieldPath,
443
+ ),
444
+ ).pipe(Atom.setIdleTTL(0));
445
+
446
+ const touchedAtom = Atom.writable(
447
+ (get) =>
448
+ (getNestedValue(
449
+ getStateOrThrow(get(stateAtom), fieldPath).touched,
450
+ fieldPath,
451
+ ) ?? false) as boolean,
452
+ (ctx, value) => {
453
+ const currentState = getStateOrThrow(ctx.get(stateAtom), fieldPath);
454
+ ctx.set(
455
+ stateAtom,
456
+ Option.some({
457
+ ...currentState,
458
+ touched: setNestedValue(currentState.touched, fieldPath, value),
459
+ }),
460
+ );
461
+ },
462
+ ).pipe(Atom.setIdleTTL(0));
463
+
464
+ const errorAtom = Atom.readable((get) => {
465
+ const errors = get(errorsAtom);
466
+ const entry = errors.get(fieldPath);
467
+ return entry
468
+ ? Option.some(entry)
469
+ : Option.none<Validation.ErrorEntry>();
470
+ }).pipe(Atom.setIdleTTL(0));
471
+
472
+ const isDirtyAtom = isDirtyAtomFamily(fieldPath);
473
+
474
+ const validationAtom = getOrCreateValidationAtom(fieldPath, schema);
475
+
476
+ const fieldValidationCountAtom = Atom.make(0).pipe(Atom.setIdleTTL(0));
477
+
478
+ const shouldValidateAtom = Atom.readable((get) => {
479
+ if (parsedMode.validation === "onChange") return true;
480
+ if (parsedMode.validation === "onBlur")
481
+ return get(touchedAtom) || get(fieldValidationCountAtom) > 0;
482
+ return (
483
+ get(submitCountAtom) > 0 ||
484
+ get(validationCountAtom) > 0 ||
485
+ get(fieldValidationCountAtom) > 0
486
+ );
487
+ }).pipe(Atom.setIdleTTL(0));
488
+
489
+ const displayErrorAtom = Atom.readable((get) => {
490
+ const validationResult = get(validationAtom);
491
+ const storedError = get(errorAtom);
492
+ const isDirty = get(isDirtyAtom);
493
+ const isTouched = get(touchedAtom);
494
+ const submitCount = get(submitCountAtom);
495
+
496
+ const livePerFieldError = Option.flatMap(
497
+ AsyncResult.error(validationResult),
498
+ Validation.extractFirstError,
499
+ );
500
+
501
+ let validationError: Option.Option<string> = Option.none();
502
+ if (Option.isSome(livePerFieldError)) {
503
+ validationError = livePerFieldError;
504
+ } else if (Option.isSome(storedError)) {
505
+ const shouldHideStoredError =
506
+ storedError.value.source === "field" &&
507
+ (AsyncResult.isSuccess(validationResult) ||
508
+ AsyncResult.isWaiting(validationResult));
509
+ if (!shouldHideStoredError) {
510
+ validationError = Option.some(storedError.value.message);
511
+ }
512
+ }
513
+
514
+ const validationCount = get(validationCountAtom);
515
+ const fieldValidationCount = get(fieldValidationCountAtom);
516
+ const hasAttemptedValidation =
517
+ submitCount > 0 || validationCount > 0 || fieldValidationCount > 0;
518
+ const shouldShowError =
519
+ parsedMode.validation === "onChange"
520
+ ? isDirty || hasAttemptedValidation
521
+ : parsedMode.validation === "onBlur"
522
+ ? isTouched || hasAttemptedValidation
523
+ : hasAttemptedValidation;
524
+
525
+ return shouldShowError ? validationError : Option.none();
526
+ }).pipe(Atom.setIdleTTL(0));
527
+
528
+ // Every value change produces a fresh box, so `Atom.debounce` (which drops
529
+ // updates that are `Object.is`-equal to its current value) still emits when
530
+ // the value returns to what it was before the burst of changes.
531
+ const debouncedChangeAtom =
532
+ validationDebounce === null
533
+ ? null
534
+ : Atom.debounce(
535
+ Atom.readable((get) => ({ value: get(valueAtom) })).pipe(
536
+ Atom.setIdleTTL(0),
537
+ ),
538
+ validationDebounce,
539
+ );
540
+
541
+ const triggerValidationAtom = Atom.readable((get) => {
542
+ if (debouncedChangeAtom !== null) {
543
+ get.mount(debouncedChangeAtom);
544
+ get.subscribe(debouncedChangeAtom, (change) => {
545
+ if (!get.once(shouldValidateAtom)) return;
546
+ get.set(validationAtom, change.value);
547
+ });
548
+ } else {
549
+ let lastValue = get.once(valueAtom);
550
+ get.subscribe(valueAtom, (newValue) => {
551
+ if (newValue === lastValue) return;
552
+ lastValue = newValue;
553
+ if (!get.once(shouldValidateAtom)) return;
554
+ get.set(validationAtom, newValue);
555
+ });
556
+ }
557
+
558
+ if (parsedMode.validation === "onBlur") {
559
+ get.subscribe(touchedAtom, (isTouched) => {
560
+ if (isTouched) {
561
+ const currentValue = get.once(valueAtom);
562
+ get.set(validationAtom, currentValue);
563
+ }
564
+ });
565
+ }
566
+ }).pipe(Atom.setIdleTTL(0));
567
+
568
+ return {
569
+ valueAtom,
570
+ initialValueAtom,
571
+ touchedAtom,
572
+ errorAtom,
573
+ isDirtyAtom,
574
+ validationAtom,
575
+ fieldValidationCountAtom,
576
+ displayErrorAtom,
577
+ shouldValidateAtom,
578
+ triggerValidationAtom,
579
+ };
580
+ },
581
+ );
582
+
583
+ const getOrCreateFieldAtoms = (
584
+ fieldPath: string,
585
+ schema: Schema.Top,
586
+ ): FieldAtoms => {
587
+ const key: FieldFamilyKey = { path: fieldPath, schema };
588
+ fieldAtomsKeys.set(fieldPath, key);
589
+ return fieldAtomsFamily(key);
590
+ };
591
+
592
+ const resetValidationAtoms = (ctx: {
593
+ set: <R, W>(atom: Atom.Writable<R, W>, value: W) => void;
594
+ }) => {
595
+ for (const key of validationKeys.values()) {
596
+ ctx.set(validationAtomFamily(key), Atom.Reset);
597
+ }
598
+ for (const key of fieldAtomsKeys.values()) {
599
+ ctx.set(fieldAtomsFamily(key).fieldValidationCountAtom, 0);
600
+ }
601
+ };
602
+
603
+ const submitAtom = runtime
604
+ .fn<SubmitArgs>()(
605
+ (args, get) =>
606
+ Effect.gen(function* () {
607
+ const state = get(stateAtom);
608
+ if (Option.isNone(state)) {
609
+ return yield* Effect.die(
610
+ new Error(
611
+ "submit was called before the form was initialized — mount " +
612
+ "<form.Initialize defaultValues={...}> before submitting. " +
613
+ `See the "Basic Form Setup" section of the README.`,
614
+ ),
615
+ );
616
+ }
617
+ const values = state.value.values;
618
+ get.set(errorsAtom, new Map());
619
+ const decoded = yield* pipe(
620
+ Schema.decodeUnknownEffect(combinedSchema)(values, {
621
+ errors: "all",
622
+ }) as Effect.Effect<
623
+ Field.DecodedFromFields<TFields>,
624
+ Schema.SchemaError,
625
+ R
626
+ >,
627
+ Effect.tapError((parseError) =>
628
+ Effect.sync(() => {
629
+ const routedErrors =
630
+ Validation.routeErrorsWithSource(parseError);
631
+ get.set(errorsAtom, routedErrors);
632
+ // Rebase onto the latest state so edits made during the in-flight
633
+ // async decode are preserved instead of clobbered by the snapshot.
634
+ const latest = get(stateAtom);
635
+ const base = Option.isSome(latest) ? latest.value : state.value;
636
+ get.set(
637
+ stateAtom,
638
+ Option.some(operations.createSubmitState(base)),
639
+ );
640
+ }),
641
+ ),
642
+ );
643
+ // Rebase onto the latest state so a field edit made while the async
644
+ // decode was running is not silently reverted to the pre-submit snapshot.
645
+ const latestState = get(stateAtom);
646
+ const baseState = Option.isSome(latestState)
647
+ ? latestState.value
648
+ : state.value;
649
+ const submitState = operations.createSubmitState(baseState);
650
+ get.set(stateAtom, Option.some(submitState));
651
+ const result = config.onSubmit(args, {
652
+ decoded,
653
+ encoded: values,
654
+ get,
655
+ });
656
+ const output = Effect.isEffect(result)
657
+ ? yield* result as Effect.Effect<A, E, R>
658
+ : (result as A);
659
+ // Only record the values as "last submitted" once onSubmit has
660
+ // succeeded. A failed onSubmit must not be reported as a successful
661
+ // submit, otherwise revertToLastSubmit / hasChangedSinceSubmit would
662
+ // treat unsaved, failed values as persisted.
663
+ const afterSubmit = get(stateAtom);
664
+ if (Option.isSome(afterSubmit)) {
665
+ get.set(
666
+ stateAtom,
667
+ Option.some({
668
+ ...afterSubmit.value,
669
+ lastSubmittedValues: Option.some({ encoded: values, decoded }),
670
+ }),
671
+ );
672
+ }
673
+ return output;
674
+ }),
675
+ config.reactivityKeys
676
+ ? { reactivityKeys: config.reactivityKeys }
677
+ : undefined,
678
+ )
679
+ .pipe(Atom.setIdleTTL(0)) as Atom.AtomResultFn<
680
+ SubmitArgs,
681
+ A,
682
+ E | Schema.SchemaError
683
+ >;
684
+
685
+ const validateAtom = runtime
686
+ .fn<void>()((_: void, get) =>
687
+ Effect.gen(function* () {
688
+ const state = get(stateAtom);
689
+ if (Option.isNone(state)) return;
690
+ const values = state.value.values;
691
+ get.set(errorsAtom, new Map());
692
+ yield* pipe(
693
+ Schema.decodeUnknownEffect(combinedSchema)(values, {
694
+ errors: "all",
695
+ }) as Effect.Effect<
696
+ Field.DecodedFromFields<TFields>,
697
+ Schema.SchemaError,
698
+ R
699
+ >,
700
+ Effect.catchTag("SchemaError", (parseError) =>
701
+ Effect.sync(() => {
702
+ const routedErrors = Validation.routeErrorsWithSource(parseError);
703
+ get.set(errorsAtom, routedErrors);
704
+ }),
705
+ ),
706
+ );
707
+ const currentState = get(stateAtom);
708
+ if (Option.isSome(currentState)) {
709
+ get.set(
710
+ stateAtom,
711
+ Option.some({
712
+ ...currentState.value,
713
+ validationCount: currentState.value.validationCount + 1,
714
+ }),
715
+ );
716
+ }
717
+ }),
718
+ )
719
+ .pipe(Atom.setIdleTTL(0)) as Atom.AtomResultFn<void, void, never>;
720
+
721
+ const fieldRefs = Object.fromEntries(
722
+ Object.keys(fields).map((key) => [key, FormBuilder.makeFieldRef(key)]),
723
+ ) as FieldRefs<TFields>;
724
+
725
+ const operations: FormOperations<TFields> = {
726
+ createInitialState: (defaultValues) => ({
727
+ values: defaultValues,
728
+ initialValues: defaultValues,
729
+ lastSubmittedValues: Option.none(),
730
+ touched: Field.createTouchedRecord(fields, false) as {
731
+ readonly [K in keyof TFields]: boolean;
732
+ },
733
+ submitCount: 0,
734
+ validationCount: 0,
735
+ dirtyFields: new Set(),
736
+ }),
737
+
738
+ createResetState: (state) => ({
739
+ values: state.initialValues,
740
+ initialValues: state.initialValues,
741
+ lastSubmittedValues: Option.none(),
742
+ touched: Field.createTouchedRecord(fields, false) as {
743
+ readonly [K in keyof TFields]: boolean;
744
+ },
745
+ submitCount: 0,
746
+ validationCount: 0,
747
+ dirtyFields: new Set(),
748
+ }),
749
+
750
+ createSubmitState: (state) => ({
751
+ ...state,
752
+ touched: Field.createTouchedRecord(fields, true) as {
753
+ readonly [K in keyof TFields]: boolean;
754
+ },
755
+ submitCount: state.submitCount + 1,
756
+ }),
757
+
758
+ setFieldValue: (state, fieldPath, value) => {
759
+ const newValues = setNestedValue(state.values, fieldPath, value);
760
+ const newDirtyFields = recalculateDirtySubtree(
761
+ state.dirtyFields,
762
+ state.initialValues,
763
+ newValues,
764
+ fieldPath,
765
+ );
766
+ return {
767
+ ...state,
768
+ values: newValues as Field.EncodedFromFields<TFields>,
769
+ dirtyFields: newDirtyFields,
770
+ };
771
+ },
772
+
773
+ setFormValues: (state, values) => {
774
+ const newDirtyFields = recalculateDirtySubtree(
775
+ state.dirtyFields,
776
+ state.initialValues,
777
+ values,
778
+ "",
779
+ );
780
+ return {
781
+ ...state,
782
+ values,
783
+ dirtyFields: newDirtyFields,
784
+ };
785
+ },
786
+
787
+ setFieldTouched: (state, fieldPath, touched) => ({
788
+ ...state,
789
+ touched: setNestedValue(state.touched, fieldPath, touched) as {
790
+ readonly [K in keyof TFields]: boolean;
791
+ },
792
+ }),
793
+
794
+ appendArrayItem: (state, arrayPath, itemSchema, value) => {
795
+ const newItem = value ?? Field.getDefaultFromSchema(itemSchema);
796
+ const currentItems = (getNestedValue(state.values, arrayPath) ??
797
+ []) as ReadonlyArray<unknown>;
798
+ const newItems = [...currentItems, newItem];
799
+ return {
800
+ ...state,
801
+ values: setNestedValue(
802
+ state.values,
803
+ arrayPath,
804
+ newItems,
805
+ ) as Field.EncodedFromFields<TFields>,
806
+ dirtyFields: recalculateDirtyFieldsForArray(
807
+ state.dirtyFields,
808
+ state.initialValues,
809
+ arrayPath,
810
+ newItems,
811
+ ),
812
+ };
813
+ },
814
+
815
+ removeArrayItem: (state, arrayPath, index) => {
816
+ const currentItems = (getNestedValue(state.values, arrayPath) ??
817
+ []) as ReadonlyArray<unknown>;
818
+ const newItems = currentItems.filter((_, i) => i !== index);
819
+ return {
820
+ ...state,
821
+ values: setNestedValue(
822
+ state.values,
823
+ arrayPath,
824
+ newItems,
825
+ ) as Field.EncodedFromFields<TFields>,
826
+ dirtyFields: recalculateDirtyFieldsForArray(
827
+ state.dirtyFields,
828
+ state.initialValues,
829
+ arrayPath,
830
+ newItems,
831
+ ),
832
+ };
833
+ },
834
+
835
+ swapArrayItems: (state, arrayPath, indexA, indexB) => {
836
+ const currentItems = (getNestedValue(state.values, arrayPath) ??
837
+ []) as ReadonlyArray<unknown>;
838
+ if (
839
+ indexA < 0 ||
840
+ indexA >= currentItems.length ||
841
+ indexB < 0 ||
842
+ indexB >= currentItems.length ||
843
+ indexA === indexB
844
+ ) {
845
+ return state;
846
+ }
847
+ const newItems = [...currentItems];
848
+ const temp = newItems[indexA];
849
+ newItems[indexA] = newItems[indexB];
850
+ newItems[indexB] = temp;
851
+ return {
852
+ ...state,
853
+ values: setNestedValue(
854
+ state.values,
855
+ arrayPath,
856
+ newItems,
857
+ ) as Field.EncodedFromFields<TFields>,
858
+ dirtyFields: recalculateDirtyFieldsForArray(
859
+ state.dirtyFields,
860
+ state.initialValues,
861
+ arrayPath,
862
+ newItems,
863
+ ),
864
+ };
865
+ },
866
+
867
+ moveArrayItem: (state, arrayPath, fromIndex, toIndex) => {
868
+ const currentItems = (getNestedValue(state.values, arrayPath) ??
869
+ []) as ReadonlyArray<unknown>;
870
+ if (
871
+ fromIndex < 0 ||
872
+ fromIndex >= currentItems.length ||
873
+ toIndex < 0 ||
874
+ toIndex > currentItems.length ||
875
+ fromIndex === toIndex
876
+ ) {
877
+ return state;
878
+ }
879
+ const newItems = [...currentItems];
880
+ const [item] = newItems.splice(fromIndex, 1);
881
+ newItems.splice(toIndex, 0, item);
882
+ return {
883
+ ...state,
884
+ values: setNestedValue(
885
+ state.values,
886
+ arrayPath,
887
+ newItems,
888
+ ) as Field.EncodedFromFields<TFields>,
889
+ dirtyFields: recalculateDirtyFieldsForArray(
890
+ state.dirtyFields,
891
+ state.initialValues,
892
+ arrayPath,
893
+ newItems,
894
+ ),
895
+ };
896
+ },
897
+
898
+ revertToLastSubmit: (state) => {
899
+ if (Option.isNone(state.lastSubmittedValues)) {
900
+ return state;
901
+ }
902
+
903
+ const lastEncoded = state.lastSubmittedValues.value.encoded;
904
+ if (state.values === lastEncoded) {
905
+ return state;
906
+ }
907
+
908
+ const newDirtyFields = recalculateDirtySubtree(
909
+ state.dirtyFields,
910
+ state.initialValues,
911
+ lastEncoded,
912
+ "",
913
+ );
914
+
915
+ return {
916
+ ...state,
917
+ values: lastEncoded,
918
+ dirtyFields: newDirtyFields,
919
+ };
920
+ },
921
+ };
922
+
923
+ const resetAtom = Atom.fnSync<void>()(
924
+ (_: void, get) => {
925
+ const state = get(stateAtom);
926
+ if (Option.isNone(state)) return;
927
+ get.set(stateAtom, Option.some(operations.createResetState(state.value)));
928
+ get.set(errorsAtom, new Map());
929
+ resetValidationAtoms(get);
930
+ get.set(submitAtom, Atom.Reset);
931
+ get.set(validateAtom, Atom.Reset);
932
+ },
933
+ { initialValue: undefined as void },
934
+ ).pipe(Atom.setIdleTTL(0));
935
+
936
+ const revertToLastSubmitAtom = Atom.fnSync<void>()(
937
+ (_: void, get) => {
938
+ const state = get(stateAtom);
939
+ if (Option.isNone(state)) return;
940
+ get.set(
941
+ stateAtom,
942
+ Option.some(operations.revertToLastSubmit(state.value)),
943
+ );
944
+ get.set(errorsAtom, new Map());
945
+ },
946
+ { initialValue: undefined as void },
947
+ ).pipe(Atom.setIdleTTL(0));
948
+
949
+ const setValuesAtom = Atom.writable(
950
+ (get): Field.EncodedFromFields<TFields> =>
951
+ pipe(
952
+ get(stateAtom),
953
+ Option.map((s) => s.values),
954
+ Option.getOrElse(() => undefined as never),
955
+ ),
956
+ (ctx, values: Field.EncodedFromFields<TFields>) => {
957
+ const state = ctx.get(stateAtom);
958
+ if (Option.isNone(state)) return;
959
+ ctx.set(
960
+ stateAtom,
961
+ Option.some(operations.setFormValues(state.value, values)),
962
+ );
963
+ ctx.set(errorsAtom, new Map());
964
+ },
965
+ ).pipe(Atom.setIdleTTL(0));
966
+
967
+ const setValueFamily = Atom.family(
968
+ (fieldKey: string): Atom.Writable<void, any> =>
969
+ Atom.fnSync<any>()(
970
+ (update, get) => {
971
+ const state = get(stateAtom);
972
+ if (Option.isNone(state)) return;
973
+
974
+ const currentValue = getNestedValue(state.value.values, fieldKey);
975
+ const newValue =
976
+ typeof update === "function" ? update(currentValue) : update;
977
+
978
+ get.set(
979
+ stateAtom,
980
+ Option.some(
981
+ operations.setFieldValue(state.value, fieldKey, newValue),
982
+ ),
983
+ );
984
+ // Don't clear errors - display logic handles showing/hiding based on source + validation state
985
+ },
986
+ { initialValue: undefined as void },
987
+ ).pipe(Atom.setIdleTTL(0)),
988
+ );
989
+
990
+ const publicFieldAtomsFamily = Atom.family(
991
+ (fieldKey: string): PublicFieldAtoms<unknown> => {
992
+ const schema = fieldSchemasByKey.get(fieldKey);
993
+ if (!schema) throw new Error(`No schema found for field "${fieldKey}"`);
994
+
995
+ const internal = getOrCreateFieldAtoms(fieldKey, schema);
996
+
997
+ const value = Atom.readable((get) =>
998
+ Option.map(get(stateAtom), (state) =>
999
+ getNestedValue(state.values, fieldKey),
1000
+ ),
1001
+ ).pipe(Atom.setIdleTTL(0));
1002
+
1003
+ const error = Atom.readable((get) =>
1004
+ Option.match(get(stateAtom), {
1005
+ onNone: () => Option.none<string>(),
1006
+ onSome: () => get(internal.displayErrorAtom),
1007
+ }),
1008
+ ).pipe(Atom.setIdleTTL(0));
1009
+
1010
+ const isDirty = isDirtyAtomFamily(fieldKey);
1011
+
1012
+ const isTouched = Atom.readable((get) =>
1013
+ Option.match(get(stateAtom), {
1014
+ onNone: () => false,
1015
+ onSome: (state) =>
1016
+ (getNestedValue(state.touched, fieldKey) ?? false) as boolean,
1017
+ }),
1018
+ ).pipe(Atom.setIdleTTL(0));
1019
+
1020
+ const isValidating = Atom.readable((get) =>
1021
+ AsyncResult.isWaiting(get(internal.validationAtom)),
1022
+ ).pipe(Atom.setIdleTTL(0));
1023
+
1024
+ const setValueAtom = setValueFamily(fieldKey);
1025
+
1026
+ const setTouchedAtom = Atom.fnSync<boolean>()(
1027
+ (touched, get) => {
1028
+ const state = get(stateAtom);
1029
+ if (Option.isNone(state)) return;
1030
+ get.set(
1031
+ stateAtom,
1032
+ Option.some(
1033
+ operations.setFieldTouched(state.value, fieldKey, touched),
1034
+ ),
1035
+ );
1036
+ },
1037
+ { initialValue: undefined as void },
1038
+ ).pipe(Atom.setIdleTTL(0));
1039
+
1040
+ const validateFieldAtom = Atom.fnSync<void>()(
1041
+ (_: void, get) => {
1042
+ const value = get(internal.valueAtom);
1043
+ get.set(internal.validationAtom as Atom.Writable<any, any>, value);
1044
+ get.set(
1045
+ internal.fieldValidationCountAtom,
1046
+ get(internal.fieldValidationCountAtom) + 1,
1047
+ );
1048
+ },
1049
+ { initialValue: undefined as void },
1050
+ ).pipe(Atom.setIdleTTL(0));
1051
+
1052
+ return {
1053
+ value,
1054
+ error,
1055
+ isDirty,
1056
+ isTouched,
1057
+ isValidating,
1058
+ setValue: setValueAtom,
1059
+ setTouched: setTouchedAtom,
1060
+ validate: validateFieldAtom,
1061
+ };
1062
+ },
1063
+ );
1064
+
1065
+ const getFieldAtoms = <S>(
1066
+ field: FormBuilder.FieldRef<S>,
1067
+ ): PublicFieldAtoms<S> =>
1068
+ publicFieldAtomsFamily(field.key) as PublicFieldAtoms<S>;
1069
+
1070
+ const mountAtom = Atom.readable((get) => {
1071
+ get(stateAtom);
1072
+ get(errorsAtom);
1073
+ get(submitAtom);
1074
+ }).pipe(Atom.setIdleTTL(0));
1075
+
1076
+ const keepAliveActiveAtom = Atom.make(false).pipe(Atom.setIdleTTL(0));
1077
+
1078
+ const autoSubmitAtom: Atom.Atom<void> =
1079
+ parsedMode.autoSubmit && parsedMode.validation === "onChange"
1080
+ ? (() => {
1081
+ // Submit requests are funneled through a monotonically increasing counter
1082
+ // so `Atom.debounce` can own the timer lifecycle: every bump restarts the
1083
+ // trailing debounce window, and the subscriber below fires once it lands.
1084
+ const submitRequestAtom = Atom.make(0).pipe(Atom.setIdleTTL(0));
1085
+ const debouncedSubmitRequestAtom =
1086
+ autoSubmitDebounce === null
1087
+ ? null
1088
+ : Atom.debounce(submitRequestAtom, autoSubmitDebounce);
1089
+
1090
+ return Atom.readable((get) => {
1091
+ const initialState = get.once(stateAtom);
1092
+ let lastValues: unknown = Option.isSome(initialState)
1093
+ ? initialState.value.values
1094
+ : null;
1095
+ let pendingChanges = false;
1096
+ let wasSubmitting = false;
1097
+
1098
+ const triggerSubmit = () => {
1099
+ if (AsyncResult.isWaiting(get.once(submitAtom))) {
1100
+ pendingChanges = true;
1101
+ return;
1102
+ }
1103
+ get.set(submitAtom as Atom.Writable<any, any>, undefined);
1104
+ };
1105
+
1106
+ let requestSubmit: () => void;
1107
+ if (debouncedSubmitRequestAtom === null) {
1108
+ requestSubmit = triggerSubmit;
1109
+ } else {
1110
+ get.mount(debouncedSubmitRequestAtom);
1111
+ get.subscribe(debouncedSubmitRequestAtom, () => {
1112
+ triggerSubmit();
1113
+ });
1114
+ requestSubmit = () => {
1115
+ get.set(submitRequestAtom, get.once(submitRequestAtom) + 1);
1116
+ };
1117
+ }
1118
+
1119
+ get.subscribe(stateAtom, () => {
1120
+ const state = get.once(stateAtom);
1121
+ if (Option.isNone(state)) return;
1122
+ const currentValues = state.value.values;
1123
+ if (currentValues === lastValues) return;
1124
+ lastValues = currentValues;
1125
+
1126
+ const submitResult = get.once(submitAtom);
1127
+ if (AsyncResult.isWaiting(submitResult)) {
1128
+ pendingChanges = true;
1129
+ } else {
1130
+ requestSubmit();
1131
+ }
1132
+ });
1133
+
1134
+ get.subscribe(submitAtom, () => {
1135
+ const result = get.once(submitAtom);
1136
+ const isSubmitting = AsyncResult.isWaiting(result);
1137
+ const justFinished = wasSubmitting && !isSubmitting;
1138
+ // Update wasSubmitting BEFORE triggering a follow-up submit. requestSubmit
1139
+ // (no debounce) synchronously re-enters this subscription with the new
1140
+ // waiting=true state; if we assigned wasSubmitting afterwards we'd clobber
1141
+ // that re-entrant true with the stale false, losing the next change.
1142
+ wasSubmitting = isSubmitting;
1143
+ if (justFinished && pendingChanges) {
1144
+ pendingChanges = false;
1145
+ requestSubmit();
1146
+ }
1147
+ });
1148
+ }).pipe(Atom.setIdleTTL(0));
1149
+ })()
1150
+ : Atom.readable(() => {}).pipe(Atom.setIdleTTL(0));
1151
+
1152
+ const onBlurSubmitAtom: Atom.Writable<void, void> =
1153
+ parsedMode.autoSubmit && parsedMode.validation === "onBlur"
1154
+ ? Atom.fnSync<void>()(
1155
+ (_: void, get) => {
1156
+ if (AsyncResult.isWaiting(get(submitAtom))) return;
1157
+ const stateOption = get(stateAtom);
1158
+ if (Option.isNone(stateOption)) return;
1159
+ const { lastSubmittedValues, values } = stateOption.value;
1160
+ if (
1161
+ Option.isSome(lastSubmittedValues) &&
1162
+ values === lastSubmittedValues.value.encoded
1163
+ )
1164
+ return;
1165
+ get.set(submitAtom as Atom.Writable<any, any>, undefined);
1166
+ },
1167
+ { initialValue: undefined as void },
1168
+ ).pipe(Atom.setIdleTTL(0))
1169
+ : Atom.fnSync<void>()((_: void) => {}, {
1170
+ initialValue: undefined as void,
1171
+ }).pipe(Atom.setIdleTTL(0));
1172
+
1173
+ return {
1174
+ stateAtom,
1175
+ errorsAtom,
1176
+ rootErrorAtom,
1177
+ valuesAtom,
1178
+ dirtyFieldsAtom,
1179
+ isDirtyAtom,
1180
+ submitCountAtom,
1181
+ validationCountAtom,
1182
+ lastSubmittedValuesAtom,
1183
+ changedSinceSubmitFieldsAtom,
1184
+ hasChangedSinceSubmitAtom,
1185
+ submitAtom,
1186
+ validateAtom,
1187
+ combinedSchema,
1188
+ fieldRefs,
1189
+ getOrCreateValidationAtom,
1190
+ getOrCreateFieldAtoms,
1191
+ resetValidationAtoms,
1192
+ operations,
1193
+ resetAtom,
1194
+ revertToLastSubmitAtom,
1195
+ setValuesAtom,
1196
+ getFieldAtoms,
1197
+ autoSubmitAtom,
1198
+ onBlurSubmitAtom,
1199
+ mountAtom,
1200
+ keepAliveActiveAtom,
1201
+ } as FormAtoms<TFields, R, A, E, SubmitArgs>;
1202
+ };