@lucas-barake/effect-form-react 0.12.0 → 0.13.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/cjs/FormReact.js +128 -75
- package/dist/cjs/FormReact.js.map +1 -1
- package/dist/dts/FormReact.d.ts +41 -31
- package/dist/dts/FormReact.d.ts.map +1 -1
- package/dist/esm/FormReact.js +128 -75
- package/dist/esm/FormReact.js.map +1 -1
- package/package.json +2 -2
- package/src/FormReact.tsx +220 -110
package/src/FormReact.tsx
CHANGED
|
@@ -1,10 +1,18 @@
|
|
|
1
|
-
import {
|
|
2
|
-
|
|
1
|
+
import {
|
|
2
|
+
RegistryContext,
|
|
3
|
+
useAtom,
|
|
4
|
+
useAtomMount,
|
|
5
|
+
useAtomSet,
|
|
6
|
+
useAtomSubscribe,
|
|
7
|
+
useAtomValue,
|
|
8
|
+
} from "@effect-atom/atom-react"
|
|
9
|
+
import * as Atom from "@effect-atom/atom/Atom"
|
|
3
10
|
import { Field, FormAtoms, Mode, Validation } from "@lucas-barake/effect-form"
|
|
4
11
|
import type * as FormBuilder from "@lucas-barake/effect-form/FormBuilder"
|
|
5
|
-
import { getNestedValue, isPathOrParentDirty
|
|
12
|
+
import { getNestedValue, isPathOrParentDirty } from "@lucas-barake/effect-form/Path"
|
|
6
13
|
import * as Cause from "effect/Cause"
|
|
7
14
|
import type * as Effect from "effect/Effect"
|
|
15
|
+
import * as Layer from "effect/Layer"
|
|
8
16
|
import * as Option from "effect/Option"
|
|
9
17
|
import * as ParseResult from "effect/ParseResult"
|
|
10
18
|
import * as Predicate from "effect/Predicate"
|
|
@@ -130,7 +138,6 @@ export type BuiltForm<
|
|
|
130
138
|
SubmitArgs = void,
|
|
131
139
|
CM extends FieldComponentMap<TFields> = FieldComponentMap<TFields>,
|
|
132
140
|
> = {
|
|
133
|
-
// Atoms for fine-grained subscriptions (use with useAtomValue)
|
|
134
141
|
readonly values: Atom.Atom<Option.Option<Field.EncodedFromFields<TFields>>>
|
|
135
142
|
readonly isDirty: Atom.Atom<boolean>
|
|
136
143
|
readonly hasChangedSinceSubmit: Atom.Atom<boolean>
|
|
@@ -151,6 +158,9 @@ export type BuiltForm<
|
|
|
151
158
|
readonly setValues: Atom.Writable<void, Field.EncodedFromFields<TFields>>
|
|
152
159
|
readonly setValue: <S>(field: FormBuilder.FieldRef<S>) => Atom.Writable<void, S | ((prev: S) => S)>
|
|
153
160
|
readonly getFieldAtom: <S>(field: FormBuilder.FieldRef<S>) => Atom.Atom<Option.Option<S>>
|
|
161
|
+
|
|
162
|
+
readonly mount: Atom.Atom<void>
|
|
163
|
+
readonly KeepAlive: React.FC
|
|
154
164
|
} & FieldComponents<TFields, CM>
|
|
155
165
|
|
|
156
166
|
type FieldComponents<TFields extends Field.FieldsRecord, CM extends FieldComponentMap<TFields>> = {
|
|
@@ -193,7 +203,7 @@ const AutoSubmitContext = createContext<(() => void) | null>(null)
|
|
|
193
203
|
const makeFieldComponent = <S extends Schema.Schema.Any, P extends Record<string, unknown>>(
|
|
194
204
|
fieldKey: string,
|
|
195
205
|
fieldDef: Field.FieldDef<string, S>,
|
|
196
|
-
|
|
206
|
+
errorsAtom: Atom.Writable<Map<string, Validation.ErrorEntry>, Map<string, Validation.ErrorEntry>>,
|
|
197
207
|
submitCountAtom: Atom.Atom<number>,
|
|
198
208
|
dirtyFieldsAtom: Atom.Atom<ReadonlySet<string>>,
|
|
199
209
|
parsedMode: Mode.ParsedMode,
|
|
@@ -209,15 +219,14 @@ const makeFieldComponent = <S extends Schema.Schema.Any, P extends Record<string
|
|
|
209
219
|
const autoSubmitOnBlur = useContext(AutoSubmitContext)
|
|
210
220
|
const fieldPath = arrayCtx ? `${arrayCtx.parentPath}.${fieldKey}` : fieldKey
|
|
211
221
|
|
|
212
|
-
const {
|
|
222
|
+
const { errorAtom, touchedAtom, valueAtom } = React.useMemo(
|
|
213
223
|
() => getOrCreateFieldAtoms(fieldPath),
|
|
214
224
|
[fieldPath],
|
|
215
225
|
)
|
|
216
226
|
|
|
217
227
|
const [value, setValue] = useAtom(valueAtom) as [Schema.Schema.Encoded<S>, (v: unknown) => void]
|
|
218
228
|
const [isTouched, setTouched] = useAtom(touchedAtom)
|
|
219
|
-
const
|
|
220
|
-
const setCrossFieldErrors = useAtomSet(crossFieldErrorsAtom)
|
|
229
|
+
const storedError = useAtomValue(errorAtom)
|
|
221
230
|
const submitCount = useAtomValue(submitCountAtom)
|
|
222
231
|
|
|
223
232
|
const validationAtom = React.useMemo(
|
|
@@ -241,13 +250,14 @@ const makeFieldComponent = <S extends Schema.Schema.Any, P extends Record<string
|
|
|
241
250
|
|
|
242
251
|
const shouldValidate = parsedMode.validation === "onChange"
|
|
243
252
|
|| (parsedMode.validation === "onBlur" && isTouched)
|
|
253
|
+
|| (parsedMode.validation === "onSubmit" && submitCount > 0)
|
|
244
254
|
|
|
245
255
|
if (shouldValidate) {
|
|
246
256
|
validate(value)
|
|
247
257
|
}
|
|
248
|
-
}, [value, isTouched, validate])
|
|
258
|
+
}, [value, isTouched, submitCount, validate])
|
|
249
259
|
|
|
250
|
-
const
|
|
260
|
+
const livePerFieldError: Option.Option<string> = React.useMemo(() => {
|
|
251
261
|
if (validationResult._tag === "Failure") {
|
|
252
262
|
const parseError = Cause.failureOption(validationResult.cause)
|
|
253
263
|
if (Option.isSome(parseError) && ParseResult.isParseError(parseError.value)) {
|
|
@@ -257,25 +267,33 @@ const makeFieldComponent = <S extends Schema.Schema.Any, P extends Record<string
|
|
|
257
267
|
return Option.none()
|
|
258
268
|
}, [validationResult])
|
|
259
269
|
|
|
260
|
-
const
|
|
270
|
+
const isValidating = validationResult.waiting
|
|
271
|
+
|
|
272
|
+
const validationError: Option.Option<string> = React.useMemo(() => {
|
|
273
|
+
if (Option.isSome(livePerFieldError)) {
|
|
274
|
+
return livePerFieldError
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
if (Option.isSome(storedError)) {
|
|
278
|
+
// Hide field-sourced errors when validation passes or is pending (async gap).
|
|
279
|
+
// Refinement errors persist until re-submit - they can't be cleared by typing.
|
|
280
|
+
const shouldHideStoredError = storedError.value.source === "field" &&
|
|
281
|
+
(validationResult._tag === "Success" || isValidating)
|
|
282
|
+
|
|
283
|
+
if (shouldHideStoredError) {
|
|
284
|
+
return Option.none()
|
|
285
|
+
}
|
|
286
|
+
return Option.some(storedError.value.message)
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
return Option.none()
|
|
290
|
+
}, [livePerFieldError, storedError, validationResult, isValidating])
|
|
261
291
|
|
|
262
292
|
const onChange = React.useCallback(
|
|
263
293
|
(newValue: Schema.Schema.Encoded<S>) => {
|
|
264
294
|
setValue(newValue)
|
|
265
|
-
setCrossFieldErrors((prev) => {
|
|
266
|
-
const next = new Map<string, string>()
|
|
267
|
-
for (const [errorPath, message] of prev) {
|
|
268
|
-
if (!isPathUnderRoot(errorPath, fieldPath)) {
|
|
269
|
-
next.set(errorPath, message)
|
|
270
|
-
}
|
|
271
|
-
}
|
|
272
|
-
return next.size !== prev.size ? next : prev
|
|
273
|
-
})
|
|
274
|
-
if (parsedMode.validation === "onChange") {
|
|
275
|
-
validate(newValue)
|
|
276
|
-
}
|
|
277
295
|
},
|
|
278
|
-
[
|
|
296
|
+
[setValue],
|
|
279
297
|
)
|
|
280
298
|
|
|
281
299
|
const onBlur = React.useCallback(() => {
|
|
@@ -291,8 +309,11 @@ const makeFieldComponent = <S extends Schema.Schema.Any, P extends Record<string
|
|
|
291
309
|
() => isPathOrParentDirty(dirtyFields, fieldPath),
|
|
292
310
|
[dirtyFields, fieldPath],
|
|
293
311
|
)
|
|
294
|
-
const
|
|
295
|
-
|
|
312
|
+
const shouldShowError = parsedMode.validation === "onChange"
|
|
313
|
+
? (isDirty || submitCount > 0)
|
|
314
|
+
: parsedMode.validation === "onBlur"
|
|
315
|
+
? (isTouched || submitCount > 0)
|
|
316
|
+
: submitCount > 0
|
|
296
317
|
|
|
297
318
|
const fieldState: FieldState<S> = React.useMemo(() => ({
|
|
298
319
|
value,
|
|
@@ -314,7 +335,7 @@ const makeArrayFieldComponent = <S extends Schema.Schema.Any>(
|
|
|
314
335
|
fieldKey: string,
|
|
315
336
|
def: Field.ArrayFieldDef<string, S>,
|
|
316
337
|
stateAtom: Atom.Writable<Option.Option<FormBuilder.FormState<any>>, Option.Option<FormBuilder.FormState<any>>>,
|
|
317
|
-
|
|
338
|
+
errorsAtom: Atom.Writable<Map<string, Validation.ErrorEntry>, Map<string, Validation.ErrorEntry>>,
|
|
318
339
|
submitCountAtom: Atom.Atom<number>,
|
|
319
340
|
dirtyFieldsAtom: Atom.Atom<ReadonlySet<string>>,
|
|
320
341
|
parsedMode: Mode.ParsedMode,
|
|
@@ -420,7 +441,7 @@ const makeArrayFieldComponent = <S extends Schema.Schema.Any>(
|
|
|
420
441
|
itemFieldComponents[itemKey] = makeFieldComponent(
|
|
421
442
|
itemKey,
|
|
422
443
|
itemDef,
|
|
423
|
-
|
|
444
|
+
errorsAtom,
|
|
424
445
|
submitCountAtom,
|
|
425
446
|
dirtyFieldsAtom,
|
|
426
447
|
parsedMode,
|
|
@@ -436,7 +457,6 @@ const makeArrayFieldComponent = <S extends Schema.Schema.Any>(
|
|
|
436
457
|
...itemFieldComponents,
|
|
437
458
|
}
|
|
438
459
|
|
|
439
|
-
// Proxy enables <Form.items.Item> and <Form.items.name> syntax
|
|
440
460
|
return new Proxy(ArrayWrapper, {
|
|
441
461
|
get(target, prop) {
|
|
442
462
|
if (prop in properties) {
|
|
@@ -456,7 +476,7 @@ const makeFieldComponents = <
|
|
|
456
476
|
Option.Option<FormBuilder.FormState<TFields>>,
|
|
457
477
|
Option.Option<FormBuilder.FormState<TFields>>
|
|
458
478
|
>,
|
|
459
|
-
|
|
479
|
+
errorsAtom: Atom.Writable<Map<string, Validation.ErrorEntry>, Map<string, Validation.ErrorEntry>>,
|
|
460
480
|
submitCountAtom: Atom.Atom<number>,
|
|
461
481
|
dirtyFieldsAtom: Atom.Atom<ReadonlySet<string>>,
|
|
462
482
|
parsedMode: Mode.ParsedMode,
|
|
@@ -477,7 +497,7 @@ const makeFieldComponents = <
|
|
|
477
497
|
key,
|
|
478
498
|
def as Field.ArrayFieldDef<string, Schema.Schema.Any>,
|
|
479
499
|
stateAtom,
|
|
480
|
-
|
|
500
|
+
errorsAtom,
|
|
481
501
|
submitCountAtom,
|
|
482
502
|
dirtyFieldsAtom,
|
|
483
503
|
parsedMode,
|
|
@@ -494,7 +514,7 @@ const makeFieldComponents = <
|
|
|
494
514
|
components[key] = makeFieldComponent(
|
|
495
515
|
key,
|
|
496
516
|
def,
|
|
497
|
-
|
|
517
|
+
errorsAtom,
|
|
498
518
|
submitCountAtom,
|
|
499
519
|
dirtyFieldsAtom,
|
|
500
520
|
parsedMode,
|
|
@@ -509,34 +529,30 @@ const makeFieldComponents = <
|
|
|
509
529
|
}
|
|
510
530
|
|
|
511
531
|
/**
|
|
512
|
-
*
|
|
532
|
+
* Creates a React form from a FormBuilder.
|
|
513
533
|
*
|
|
514
534
|
* @example
|
|
515
535
|
* ```tsx
|
|
516
536
|
* import { FormBuilder } from "@lucas-barake/effect-form"
|
|
517
537
|
* import { FormReact } from "@lucas-barake/effect-form-react"
|
|
518
538
|
* import { useAtomValue, useAtomSet } from "@effect-atom/atom-react"
|
|
519
|
-
* import * as Atom from "@effect-atom/atom/Atom"
|
|
520
539
|
* import * as Schema from "effect/Schema"
|
|
521
|
-
* import * as Layer from "effect/Layer"
|
|
522
|
-
*
|
|
523
|
-
* const runtime = Atom.runtime(Layer.empty)
|
|
524
540
|
*
|
|
525
|
-
* const
|
|
541
|
+
* const loginFormBuilder = FormBuilder.empty
|
|
526
542
|
* .addField("email", Schema.String)
|
|
527
543
|
* .addField("password", Schema.String)
|
|
528
544
|
*
|
|
529
|
-
*
|
|
530
|
-
*
|
|
545
|
+
* // Runtime is optional for forms without service requirements
|
|
546
|
+
* const loginForm = FormReact.make(loginFormBuilder, {
|
|
531
547
|
* fields: { email: TextInput, password: PasswordInput },
|
|
532
|
-
* onSubmit: (
|
|
548
|
+
* onSubmit: (_, { decoded }) => Effect.log(`Login: ${decoded.email}`),
|
|
533
549
|
* })
|
|
534
550
|
*
|
|
535
551
|
* // Subscribe to atoms anywhere in the tree
|
|
536
552
|
* function SubmitButton() {
|
|
537
|
-
* const isDirty = useAtomValue(
|
|
538
|
-
* const submit = useAtomValue(
|
|
539
|
-
* const callSubmit = useAtomSet(
|
|
553
|
+
* const isDirty = useAtomValue(loginForm.isDirty)
|
|
554
|
+
* const submit = useAtomValue(loginForm.submit)
|
|
555
|
+
* const callSubmit = useAtomSet(loginForm.submit)
|
|
540
556
|
* return (
|
|
541
557
|
* <button onClick={() => callSubmit()} disabled={!isDirty || submit.waiting}>
|
|
542
558
|
* {submit.waiting ? "Validating..." : "Login"}
|
|
@@ -544,48 +560,74 @@ const makeFieldComponents = <
|
|
|
544
560
|
* )
|
|
545
561
|
* }
|
|
546
562
|
*
|
|
547
|
-
* function
|
|
563
|
+
* function LoginPage() {
|
|
548
564
|
* return (
|
|
549
|
-
* <
|
|
550
|
-
* <
|
|
551
|
-
* <
|
|
565
|
+
* <loginForm.Initialize defaultValues={{ email: "", password: "" }}>
|
|
566
|
+
* <loginForm.email />
|
|
567
|
+
* <loginForm.password />
|
|
552
568
|
* <SubmitButton />
|
|
553
|
-
* </
|
|
569
|
+
* </loginForm.Initialize>
|
|
554
570
|
* )
|
|
555
571
|
* }
|
|
556
572
|
* ```
|
|
557
573
|
*
|
|
558
574
|
* @category Constructors
|
|
559
575
|
*/
|
|
560
|
-
export const
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
576
|
+
export const make: {
|
|
577
|
+
<
|
|
578
|
+
TFields extends Field.FieldsRecord,
|
|
579
|
+
A,
|
|
580
|
+
E,
|
|
581
|
+
SubmitArgs = void,
|
|
582
|
+
CM extends FieldComponentMap<TFields> = FieldComponentMap<TFields>,
|
|
583
|
+
>(
|
|
584
|
+
self: FormBuilder.FormBuilder<TFields, never>,
|
|
585
|
+
options: {
|
|
586
|
+
readonly runtime?: Atom.AtomRuntime<never, never>
|
|
587
|
+
readonly fields: CM
|
|
588
|
+
readonly mode?: SubmitArgs extends void ? Mode.FormMode : Mode.FormModeWithoutAutoSubmit
|
|
589
|
+
readonly onSubmit: (
|
|
590
|
+
args: SubmitArgs,
|
|
591
|
+
ctx: {
|
|
592
|
+
readonly decoded: Field.DecodedFromFields<TFields>
|
|
593
|
+
readonly encoded: Field.EncodedFromFields<TFields>
|
|
594
|
+
readonly get: Atom.FnContext
|
|
595
|
+
},
|
|
596
|
+
) => A | Effect.Effect<A, E, never>
|
|
597
|
+
},
|
|
598
|
+
): BuiltForm<TFields, never, A, E, SubmitArgs, CM>
|
|
599
|
+
|
|
600
|
+
<
|
|
601
|
+
TFields extends Field.FieldsRecord,
|
|
602
|
+
R,
|
|
603
|
+
A,
|
|
604
|
+
E,
|
|
605
|
+
SubmitArgs = void,
|
|
606
|
+
ER = never,
|
|
607
|
+
CM extends FieldComponentMap<TFields> = FieldComponentMap<TFields>,
|
|
608
|
+
>(
|
|
609
|
+
self: FormBuilder.FormBuilder<TFields, R>,
|
|
610
|
+
options: {
|
|
611
|
+
readonly runtime: Atom.AtomRuntime<R, ER>
|
|
612
|
+
readonly fields: CM
|
|
613
|
+
readonly mode?: SubmitArgs extends void ? Mode.FormMode : Mode.FormModeWithoutAutoSubmit
|
|
614
|
+
readonly onSubmit: (
|
|
615
|
+
args: SubmitArgs,
|
|
616
|
+
ctx: {
|
|
617
|
+
readonly decoded: Field.DecodedFromFields<TFields>
|
|
618
|
+
readonly encoded: Field.EncodedFromFields<TFields>
|
|
619
|
+
readonly get: Atom.FnContext
|
|
620
|
+
},
|
|
621
|
+
) => A | Effect.Effect<A, E, R>
|
|
622
|
+
},
|
|
623
|
+
): BuiltForm<TFields, R, A, E, SubmitArgs, CM>
|
|
624
|
+
} = (self: any, options: any): any => {
|
|
625
|
+
const { fields: components, mode, onSubmit, runtime: providedRuntime } = options
|
|
626
|
+
const runtime = providedRuntime ?? Atom.runtime(Layer.empty)
|
|
585
627
|
const parsedMode = Mode.parse(mode)
|
|
586
628
|
const { fields } = self
|
|
587
629
|
|
|
588
|
-
const formAtoms
|
|
630
|
+
const formAtoms = FormAtoms.make({
|
|
589
631
|
formBuilder: self,
|
|
590
632
|
runtime,
|
|
591
633
|
onSubmit,
|
|
@@ -593,18 +635,21 @@ export const build = <
|
|
|
593
635
|
|
|
594
636
|
const {
|
|
595
637
|
combinedSchema,
|
|
596
|
-
crossFieldErrorsAtom,
|
|
597
638
|
dirtyFieldsAtom,
|
|
639
|
+
errorsAtom,
|
|
598
640
|
fieldRefs,
|
|
599
641
|
getFieldAtom,
|
|
600
642
|
getOrCreateFieldAtoms,
|
|
601
643
|
getOrCreateValidationAtom,
|
|
602
644
|
hasChangedSinceSubmitAtom,
|
|
603
645
|
isDirtyAtom,
|
|
646
|
+
keepAliveActiveAtom,
|
|
604
647
|
lastSubmittedValuesAtom,
|
|
648
|
+
mountAtom,
|
|
605
649
|
operations,
|
|
606
650
|
resetAtom,
|
|
607
651
|
revertToLastSubmitAtom,
|
|
652
|
+
rootErrorAtom,
|
|
608
653
|
setValue,
|
|
609
654
|
setValuesAtom,
|
|
610
655
|
stateAtom,
|
|
@@ -614,7 +659,7 @@ export const build = <
|
|
|
614
659
|
} = formAtoms
|
|
615
660
|
|
|
616
661
|
const InitializeComponent: React.FC<{
|
|
617
|
-
readonly defaultValues:
|
|
662
|
+
readonly defaultValues: any
|
|
618
663
|
readonly children: React.ReactNode
|
|
619
664
|
}> = ({ children, defaultValues }) => {
|
|
620
665
|
const registry = React.useContext(RegistryContext)
|
|
@@ -624,56 +669,110 @@ export const build = <
|
|
|
624
669
|
const isInitializedRef = React.useRef(false)
|
|
625
670
|
|
|
626
671
|
React.useEffect(() => {
|
|
627
|
-
|
|
672
|
+
const isKeptAlive = registry.get(keepAliveActiveAtom)
|
|
673
|
+
const currentState = registry.get(stateAtom)
|
|
674
|
+
|
|
675
|
+
if (!isKeptAlive) {
|
|
676
|
+
setFormState(Option.some(operations.createInitialState(defaultValues)))
|
|
677
|
+
} else if (Option.isNone(currentState)) {
|
|
678
|
+
setFormState(Option.some(operations.createInitialState(defaultValues)))
|
|
679
|
+
}
|
|
680
|
+
|
|
628
681
|
isInitializedRef.current = true
|
|
629
682
|
// eslint-disable-next-line react-hooks/exhaustive-deps -- mount-only
|
|
630
|
-
}, [])
|
|
683
|
+
}, [registry])
|
|
631
684
|
|
|
632
685
|
const debouncedAutoSubmit = useDebounced(() => {
|
|
633
686
|
const stateOption = registry.get(stateAtom)
|
|
634
687
|
if (Option.isNone(stateOption)) return
|
|
635
|
-
callSubmit(undefined
|
|
688
|
+
callSubmit(undefined)
|
|
636
689
|
}, parsedMode.autoSubmit && parsedMode.validation === "onChange" ? parsedMode.debounce : null)
|
|
637
690
|
|
|
691
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
692
|
+
// Auto-Submit Coordination
|
|
693
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
694
|
+
// Two-subscription model to avoid infinite loop:
|
|
695
|
+
// - Stream 1 reacts to value changes (reference equality), triggers or queues submit
|
|
696
|
+
// - Stream 2 reacts to submit completion, flushes queued changes
|
|
697
|
+
//
|
|
698
|
+
// Single subscription to stateAtom cannot distinguish value changes from submit
|
|
699
|
+
// metadata updates (submitCount, lastSubmittedValues).
|
|
700
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
701
|
+
|
|
702
|
+
const lastValuesRef = React.useRef<unknown>(null)
|
|
703
|
+
const pendingChangesRef = React.useRef(false)
|
|
704
|
+
const wasSubmittingRef = React.useRef(false)
|
|
705
|
+
|
|
638
706
|
useAtomSubscribe(
|
|
639
707
|
stateAtom,
|
|
640
708
|
React.useCallback(() => {
|
|
641
709
|
if (!isInitializedRef.current) return
|
|
642
|
-
|
|
710
|
+
|
|
711
|
+
const state = registry.get(stateAtom)
|
|
712
|
+
if (Option.isNone(state)) return
|
|
713
|
+
const currentValues = state.value.values
|
|
714
|
+
|
|
715
|
+
// Reference equality filters out submit metadata changes.
|
|
716
|
+
// Works because setFieldValue creates new values object (immutable update).
|
|
717
|
+
if (currentValues === lastValuesRef.current) return
|
|
718
|
+
lastValuesRef.current = currentValues
|
|
719
|
+
|
|
720
|
+
if (!parsedMode.autoSubmit || parsedMode.validation !== "onChange") return
|
|
721
|
+
|
|
722
|
+
const submitResult = registry.get(submitAtom)
|
|
723
|
+
if (submitResult.waiting) {
|
|
724
|
+
pendingChangesRef.current = true
|
|
725
|
+
} else {
|
|
643
726
|
debouncedAutoSubmit()
|
|
644
727
|
}
|
|
645
|
-
}, [debouncedAutoSubmit]),
|
|
728
|
+
}, [debouncedAutoSubmit, registry]),
|
|
729
|
+
{ immediate: false },
|
|
730
|
+
)
|
|
731
|
+
|
|
732
|
+
useAtomSubscribe(
|
|
733
|
+
submitAtom,
|
|
734
|
+
React.useCallback(
|
|
735
|
+
(result) => {
|
|
736
|
+
if (!parsedMode.autoSubmit || parsedMode.validation !== "onChange") return
|
|
737
|
+
|
|
738
|
+
const isSubmitting = result.waiting
|
|
739
|
+
const wasSubmitting = wasSubmittingRef.current
|
|
740
|
+
wasSubmittingRef.current = isSubmitting
|
|
741
|
+
|
|
742
|
+
// Flush queued changes when submit completes
|
|
743
|
+
if (wasSubmitting && !isSubmitting) {
|
|
744
|
+
if (pendingChangesRef.current) {
|
|
745
|
+
pendingChangesRef.current = false
|
|
746
|
+
debouncedAutoSubmit()
|
|
747
|
+
}
|
|
748
|
+
}
|
|
749
|
+
},
|
|
750
|
+
[debouncedAutoSubmit],
|
|
751
|
+
),
|
|
646
752
|
{ immediate: false },
|
|
647
753
|
)
|
|
648
754
|
|
|
649
755
|
const onBlurAutoSubmit = React.useCallback(() => {
|
|
650
|
-
if (parsedMode.autoSubmit
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
756
|
+
if (!parsedMode.autoSubmit || parsedMode.validation !== "onBlur") return
|
|
757
|
+
|
|
758
|
+
const stateOption = registry.get(stateAtom)
|
|
759
|
+
if (Option.isNone(stateOption)) return
|
|
760
|
+
|
|
761
|
+
const { lastSubmittedValues, values } = stateOption.value
|
|
762
|
+
if (Option.isSome(lastSubmittedValues) && values === lastSubmittedValues.value.encoded) return
|
|
763
|
+
|
|
764
|
+
callSubmit(undefined)
|
|
655
765
|
}, [registry, callSubmit])
|
|
656
766
|
|
|
657
767
|
if (Option.isNone(state)) return null
|
|
658
768
|
|
|
659
|
-
return
|
|
660
|
-
<AutoSubmitContext.Provider value={onBlurAutoSubmit}>
|
|
661
|
-
<form
|
|
662
|
-
onSubmit={(e) => {
|
|
663
|
-
e.preventDefault()
|
|
664
|
-
e.stopPropagation()
|
|
665
|
-
}}
|
|
666
|
-
>
|
|
667
|
-
{children}
|
|
668
|
-
</form>
|
|
669
|
-
</AutoSubmitContext.Provider>
|
|
670
|
-
)
|
|
769
|
+
return <AutoSubmitContext.Provider value={onBlurAutoSubmit}>{children}</AutoSubmitContext.Provider>
|
|
671
770
|
}
|
|
672
771
|
|
|
673
772
|
const fieldComponents = makeFieldComponents(
|
|
674
773
|
fields,
|
|
675
774
|
stateAtom,
|
|
676
|
-
|
|
775
|
+
errorsAtom,
|
|
677
776
|
submitCountAtom,
|
|
678
777
|
dirtyFieldsAtom,
|
|
679
778
|
parsedMode,
|
|
@@ -683,12 +782,25 @@ export const build = <
|
|
|
683
782
|
components,
|
|
684
783
|
)
|
|
685
784
|
|
|
785
|
+
const KeepAlive: React.FC = () => {
|
|
786
|
+
const setKeepAliveActive = useAtomSet(keepAliveActiveAtom)
|
|
787
|
+
|
|
788
|
+
React.useLayoutEffect(() => {
|
|
789
|
+
setKeepAliveActive(true)
|
|
790
|
+
return () => setKeepAliveActive(false)
|
|
791
|
+
}, [setKeepAliveActive])
|
|
792
|
+
|
|
793
|
+
useAtomMount(mountAtom)
|
|
794
|
+
return null
|
|
795
|
+
}
|
|
796
|
+
|
|
686
797
|
return {
|
|
687
798
|
values: valuesAtom,
|
|
688
799
|
isDirty: isDirtyAtom,
|
|
689
800
|
hasChangedSinceSubmit: hasChangedSinceSubmitAtom,
|
|
690
801
|
lastSubmittedValues: lastSubmittedValuesAtom,
|
|
691
802
|
submitCount: submitCountAtom,
|
|
803
|
+
rootError: rootErrorAtom,
|
|
692
804
|
schema: combinedSchema,
|
|
693
805
|
fields: fieldRefs,
|
|
694
806
|
Initialize: InitializeComponent,
|
|
@@ -698,8 +810,10 @@ export const build = <
|
|
|
698
810
|
setValues: setValuesAtom,
|
|
699
811
|
setValue,
|
|
700
812
|
getFieldAtom,
|
|
813
|
+
mount: mountAtom,
|
|
814
|
+
KeepAlive,
|
|
701
815
|
...fieldComponents,
|
|
702
|
-
}
|
|
816
|
+
}
|
|
703
817
|
}
|
|
704
818
|
|
|
705
819
|
/**
|
|
@@ -767,10 +881,10 @@ export const forField = <K extends string, S extends Schema.Schema.Any>(
|
|
|
767
881
|
* ))
|
|
768
882
|
*
|
|
769
883
|
* // Use in form builder
|
|
770
|
-
* const
|
|
884
|
+
* const formBuilder = FormBuilder.empty.addField(NameInput.field)
|
|
771
885
|
*
|
|
772
|
-
* // Use in
|
|
773
|
-
* const
|
|
886
|
+
* // Use in make()
|
|
887
|
+
* const form = FormReact.make(formBuilder, {
|
|
774
888
|
* runtime,
|
|
775
889
|
* fields: { name: NameInput },
|
|
776
890
|
* onSubmit: (_, { decoded }) => Effect.log(decoded.name),
|
|
@@ -783,20 +897,16 @@ export const makeField = <K extends string, S extends Schema.Schema.Any>(options
|
|
|
783
897
|
readonly key: K
|
|
784
898
|
readonly schema: S
|
|
785
899
|
}): <P extends Record<string, unknown> = Record<string, never>>(
|
|
786
|
-
component: React.FC<FieldComponentProps<S, P
|
|
900
|
+
component: React.FC<FieldComponentProps<S, P>>,
|
|
787
901
|
) => FieldBundle<K, S, P> => {
|
|
788
902
|
const field = Field.makeField(options.key, options.schema)
|
|
789
903
|
return (component) => {
|
|
790
|
-
// DX: Auto-generate a readable component name for DevTools
|
|
791
|
-
// e.g., key "email" -> "<EmailField />"
|
|
792
904
|
if (!component.displayName) {
|
|
793
905
|
const displayName = `${options.key.charAt(0).toUpperCase()}${options.key.slice(1)}Field`
|
|
794
|
-
// Cast to 'any' because strict TS marks function names as readonly,
|
|
795
|
-
// but React relies on mutation here.
|
|
796
906
|
try {
|
|
797
907
|
;(component as any).displayName = displayName
|
|
798
908
|
} catch {
|
|
799
|
-
// Ignore
|
|
909
|
+
// Ignore - some environments freeze function properties
|
|
800
910
|
}
|
|
801
911
|
}
|
|
802
912
|
return {
|