@pyreon/form 0.0.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.
@@ -0,0 +1,1860 @@
1
+ import { h } from '@pyreon/core'
2
+ import { mount } from '@pyreon/runtime-dom'
3
+ import {
4
+ useForm,
5
+ useFieldArray,
6
+ useField,
7
+ useWatch,
8
+ useFormState,
9
+ FormProvider,
10
+ useFormContext,
11
+ } from '../index'
12
+ import type { FormState } from '../index'
13
+
14
+ // ─── Helpers ──────────────────────────────────────────────────────────────────
15
+
16
+ function mountWith<T>(fn: () => T): { result: T; unmount: () => void } {
17
+ let result: T | undefined
18
+ const el = document.createElement('div')
19
+ document.body.appendChild(el)
20
+ const unmount = mount(
21
+ h(() => {
22
+ result = fn()
23
+ return null
24
+ }, null),
25
+ el,
26
+ )
27
+ return {
28
+ result: result!,
29
+ unmount: () => {
30
+ unmount()
31
+ el.remove()
32
+ },
33
+ }
34
+ }
35
+
36
+ type LoginForm = {
37
+ email: string
38
+ password: string
39
+ }
40
+
41
+ // ─── useForm ─────────────────────────────────────────────────────────────────
42
+
43
+ describe('useForm', () => {
44
+ it('initializes with correct values', () => {
45
+ const { result: form, unmount } = mountWith(() =>
46
+ useForm({
47
+ initialValues: { email: 'test@test.com', password: '' },
48
+ onSubmit: () => {
49
+ /* noop */
50
+ },
51
+ }),
52
+ )
53
+
54
+ expect(form.fields.email.value()).toBe('test@test.com')
55
+ expect(form.fields.password.value()).toBe('')
56
+ expect(form.isValid()).toBe(true)
57
+ expect(form.isDirty()).toBe(false)
58
+ expect(form.isSubmitting()).toBe(false)
59
+ expect(form.submitCount()).toBe(0)
60
+ unmount()
61
+ })
62
+
63
+ it('setValue updates field value and marks dirty', () => {
64
+ const { result: form, unmount } = mountWith(() =>
65
+ useForm<LoginForm>({
66
+ initialValues: { email: '', password: '' },
67
+ onSubmit: () => {
68
+ /* noop */
69
+ },
70
+ }),
71
+ )
72
+
73
+ form.fields.email.setValue('hello@world.com')
74
+ expect(form.fields.email.value()).toBe('hello@world.com')
75
+ expect(form.fields.email.dirty()).toBe(true)
76
+ expect(form.isDirty()).toBe(true)
77
+ unmount()
78
+ })
79
+
80
+ it('setTouched marks field as touched', () => {
81
+ const { result: form, unmount } = mountWith(() =>
82
+ useForm<LoginForm>({
83
+ initialValues: { email: '', password: '' },
84
+ onSubmit: () => {
85
+ /* noop */
86
+ },
87
+ }),
88
+ )
89
+
90
+ expect(form.fields.email.touched()).toBe(false)
91
+ form.fields.email.setTouched()
92
+ expect(form.fields.email.touched()).toBe(true)
93
+ unmount()
94
+ })
95
+
96
+ it('field-level validation on blur', async () => {
97
+ const { result: form, unmount } = mountWith(() =>
98
+ useForm<LoginForm>({
99
+ initialValues: { email: '', password: '' },
100
+ validators: {
101
+ email: (v) => (!v ? 'Required' : undefined),
102
+ },
103
+ onSubmit: () => {
104
+ /* noop */
105
+ },
106
+ validateOn: 'blur',
107
+ }),
108
+ )
109
+
110
+ // No error initially
111
+ expect(form.fields.email.error()).toBeUndefined()
112
+
113
+ // Trigger blur
114
+ form.fields.email.setTouched()
115
+ await new Promise((r) => setTimeout(r, 0))
116
+
117
+ expect(form.fields.email.error()).toBe('Required')
118
+ expect(form.isValid()).toBe(false)
119
+
120
+ // Fix the value
121
+ form.fields.email.setValue('test@test.com')
122
+ form.fields.email.setTouched()
123
+ await new Promise((r) => setTimeout(r, 0))
124
+
125
+ expect(form.fields.email.error()).toBeUndefined()
126
+ expect(form.isValid()).toBe(true)
127
+ unmount()
128
+ })
129
+
130
+ it('field-level validation on change', async () => {
131
+ const { result: form, unmount } = mountWith(() =>
132
+ useForm<LoginForm>({
133
+ initialValues: { email: '', password: '' },
134
+ validators: {
135
+ email: (v) => (!v ? 'Required' : undefined),
136
+ },
137
+ onSubmit: () => {
138
+ /* noop */
139
+ },
140
+ validateOn: 'change',
141
+ }),
142
+ )
143
+
144
+ // Error should be set immediately via effect
145
+ await new Promise((r) => setTimeout(r, 0))
146
+ expect(form.fields.email.error()).toBe('Required')
147
+
148
+ form.fields.email.setValue('hello')
149
+ await new Promise((r) => setTimeout(r, 0))
150
+ expect(form.fields.email.error()).toBeUndefined()
151
+ unmount()
152
+ })
153
+
154
+ it('handleSubmit validates and calls onSubmit when valid', async () => {
155
+ let submitted: LoginForm | undefined
156
+ const { result: form, unmount } = mountWith(() =>
157
+ useForm<LoginForm>({
158
+ initialValues: { email: 'a@b.com', password: '12345678' },
159
+ validators: {
160
+ email: (v) => (!v ? 'Required' : undefined),
161
+ password: (v) => (v.length < 8 ? 'Too short' : undefined),
162
+ },
163
+ onSubmit: (values) => {
164
+ submitted = values
165
+ },
166
+ }),
167
+ )
168
+
169
+ await form.handleSubmit()
170
+ expect(submitted).toEqual({ email: 'a@b.com', password: '12345678' })
171
+ expect(form.submitCount()).toBe(1)
172
+ unmount()
173
+ })
174
+
175
+ it('handleSubmit does not call onSubmit when invalid', async () => {
176
+ let called = false
177
+ const { result: form, unmount } = mountWith(() =>
178
+ useForm<LoginForm>({
179
+ initialValues: { email: '', password: '' },
180
+ validators: {
181
+ email: (v) => (!v ? 'Required' : undefined),
182
+ },
183
+ onSubmit: () => {
184
+ called = true
185
+ },
186
+ }),
187
+ )
188
+
189
+ await form.handleSubmit()
190
+ expect(called).toBe(false)
191
+ expect(form.submitCount()).toBe(1)
192
+ expect(form.fields.email.error()).toBe('Required')
193
+ expect(form.fields.email.touched()).toBe(true)
194
+ unmount()
195
+ })
196
+
197
+ it('handleSubmit sets isSubmitting during async onSubmit', async () => {
198
+ const states: boolean[] = []
199
+ const { result: form, unmount } = mountWith(() =>
200
+ useForm<LoginForm>({
201
+ initialValues: { email: 'a@b.com', password: '12345678' },
202
+ onSubmit: async () => {
203
+ states.push(form.isSubmitting())
204
+ await new Promise((r) => setTimeout(r, 10))
205
+ },
206
+ }),
207
+ )
208
+
209
+ const submitPromise = form.handleSubmit()
210
+ // Should not be submitting yet at this exact moment (microtask)
211
+ await submitPromise
212
+ expect(states[0]).toBe(true)
213
+ expect(form.isSubmitting()).toBe(false)
214
+ unmount()
215
+ })
216
+
217
+ it('schema validation runs after field validators', async () => {
218
+ let submitted = false
219
+ const { result: form, unmount } = mountWith(() =>
220
+ useForm({
221
+ initialValues: { password: '12345678', confirmPassword: '12345679' },
222
+ schema: (values) => {
223
+ const errors: Partial<
224
+ Record<'password' | 'confirmPassword', string>
225
+ > = {}
226
+ if (values.password !== values.confirmPassword) {
227
+ errors.confirmPassword = 'Passwords must match'
228
+ }
229
+ return errors
230
+ },
231
+ onSubmit: () => {
232
+ submitted = true
233
+ },
234
+ }),
235
+ )
236
+
237
+ await form.handleSubmit()
238
+ expect(submitted).toBe(false)
239
+ expect(form.fields.confirmPassword.error()).toBe('Passwords must match')
240
+
241
+ // Fix the value
242
+ form.fields.confirmPassword.setValue('12345678')
243
+ await form.handleSubmit()
244
+ expect(submitted).toBe(true)
245
+ unmount()
246
+ })
247
+
248
+ it('values() returns all current values', () => {
249
+ const { result: form, unmount } = mountWith(() =>
250
+ useForm<LoginForm>({
251
+ initialValues: { email: 'a@b.com', password: 'secret' },
252
+ onSubmit: () => {
253
+ /* noop */
254
+ },
255
+ }),
256
+ )
257
+
258
+ expect(form.values()).toEqual({ email: 'a@b.com', password: 'secret' })
259
+ form.fields.email.setValue('new@email.com')
260
+ expect(form.values()).toEqual({
261
+ email: 'new@email.com',
262
+ password: 'secret',
263
+ })
264
+ unmount()
265
+ })
266
+
267
+ it('errors() returns all current errors', async () => {
268
+ const { result: form, unmount } = mountWith(() =>
269
+ useForm<LoginForm>({
270
+ initialValues: { email: '', password: '' },
271
+ validators: {
272
+ email: (v) => (!v ? 'Required' : undefined),
273
+ password: (v) => (!v ? 'Required' : undefined),
274
+ },
275
+ onSubmit: () => {
276
+ /* noop */
277
+ },
278
+ }),
279
+ )
280
+
281
+ await form.validate()
282
+ expect(form.errors()).toEqual({
283
+ email: 'Required',
284
+ password: 'Required',
285
+ })
286
+ unmount()
287
+ })
288
+
289
+ it('reset() restores initial values and clears state', async () => {
290
+ const { result: form, unmount } = mountWith(() =>
291
+ useForm<LoginForm>({
292
+ initialValues: { email: '', password: '' },
293
+ validators: {
294
+ email: (v) => (!v ? 'Required' : undefined),
295
+ },
296
+ onSubmit: () => {
297
+ /* noop */
298
+ },
299
+ }),
300
+ )
301
+
302
+ form.fields.email.setValue('changed')
303
+ form.fields.email.setTouched()
304
+ await form.handleSubmit()
305
+
306
+ form.reset()
307
+ expect(form.fields.email.value()).toBe('')
308
+ expect(form.fields.email.error()).toBeUndefined()
309
+ expect(form.fields.email.touched()).toBe(false)
310
+ expect(form.fields.email.dirty()).toBe(false)
311
+ expect(form.submitCount()).toBe(0)
312
+ unmount()
313
+ })
314
+
315
+ it('validate() returns true when all valid', async () => {
316
+ const { result: form, unmount } = mountWith(() =>
317
+ useForm<LoginForm>({
318
+ initialValues: { email: 'test@test.com', password: '12345678' },
319
+ validators: {
320
+ email: (v) => (!v ? 'Required' : undefined),
321
+ },
322
+ onSubmit: () => {
323
+ /* noop */
324
+ },
325
+ }),
326
+ )
327
+
328
+ const valid = await form.validate()
329
+ expect(valid).toBe(true)
330
+ unmount()
331
+ })
332
+
333
+ it('async validators work', async () => {
334
+ const { result: form, unmount } = mountWith(() =>
335
+ useForm({
336
+ initialValues: { username: 'taken' },
337
+ validators: {
338
+ username: async (v) => {
339
+ await new Promise((r) => setTimeout(r, 5))
340
+ return v === 'taken' ? 'Already taken' : undefined
341
+ },
342
+ },
343
+ onSubmit: () => {
344
+ /* noop */
345
+ },
346
+ }),
347
+ )
348
+
349
+ const valid = await form.validate()
350
+ expect(valid).toBe(false)
351
+ expect(form.fields.username.error()).toBe('Already taken')
352
+ unmount()
353
+ })
354
+
355
+ it('setting value back to initial clears dirty', () => {
356
+ const { result: form, unmount } = mountWith(() =>
357
+ useForm<LoginForm>({
358
+ initialValues: { email: 'original', password: '' },
359
+ onSubmit: () => {
360
+ /* noop */
361
+ },
362
+ }),
363
+ )
364
+
365
+ form.fields.email.setValue('changed')
366
+ expect(form.fields.email.dirty()).toBe(true)
367
+
368
+ form.fields.email.setValue('original')
369
+ expect(form.fields.email.dirty()).toBe(false)
370
+ expect(form.isDirty()).toBe(false)
371
+ unmount()
372
+ })
373
+
374
+ it('cross-field validation — validators receive all form values', async () => {
375
+ const { result: form, unmount } = mountWith(() =>
376
+ useForm({
377
+ initialValues: { password: 'abc123', confirmPassword: 'different' },
378
+ validators: {
379
+ confirmPassword: (value, allValues) =>
380
+ value !== allValues.password ? 'Passwords must match' : undefined,
381
+ },
382
+ onSubmit: () => {
383
+ /* noop */
384
+ },
385
+ }),
386
+ )
387
+
388
+ const valid = await form.validate()
389
+ expect(valid).toBe(false)
390
+ expect(form.fields.confirmPassword.error()).toBe('Passwords must match')
391
+
392
+ form.fields.confirmPassword.setValue('abc123')
393
+ const valid2 = await form.validate()
394
+ expect(valid2).toBe(true)
395
+ expect(form.fields.confirmPassword.error()).toBeUndefined()
396
+ unmount()
397
+ })
398
+
399
+ it('register() returns value signal and event handlers', () => {
400
+ const { result: form, unmount } = mountWith(() =>
401
+ useForm<LoginForm>({
402
+ initialValues: { email: '', password: '' },
403
+ onSubmit: () => {
404
+ /* noop */
405
+ },
406
+ }),
407
+ )
408
+
409
+ const props = form.register('email')
410
+ expect(props.value).toBe(form.fields.email.value)
411
+ expect(typeof props.onInput).toBe('function')
412
+ expect(typeof props.onBlur).toBe('function')
413
+ unmount()
414
+ })
415
+
416
+ it('register() onInput updates field value', () => {
417
+ const { result: form, unmount } = mountWith(() =>
418
+ useForm<LoginForm>({
419
+ initialValues: { email: '', password: '' },
420
+ onSubmit: () => {
421
+ /* noop */
422
+ },
423
+ }),
424
+ )
425
+
426
+ const props = form.register('email')
427
+ const fakeEvent = { target: { value: 'test@test.com' } } as unknown as Event
428
+ props.onInput(fakeEvent)
429
+
430
+ expect(form.fields.email.value()).toBe('test@test.com')
431
+ expect(form.fields.email.dirty()).toBe(true)
432
+ unmount()
433
+ })
434
+
435
+ it('register() onBlur marks field as touched and validates', async () => {
436
+ const { result: form, unmount } = mountWith(() =>
437
+ useForm<LoginForm>({
438
+ initialValues: { email: '', password: '' },
439
+ validators: {
440
+ email: (v) => (!v ? 'Required' : undefined),
441
+ },
442
+ onSubmit: () => {
443
+ /* noop */
444
+ },
445
+ validateOn: 'blur',
446
+ }),
447
+ )
448
+
449
+ const props = form.register('email')
450
+ props.onBlur()
451
+ await new Promise((r) => setTimeout(r, 0))
452
+
453
+ expect(form.fields.email.touched()).toBe(true)
454
+ expect(form.fields.email.error()).toBe('Required')
455
+ unmount()
456
+ })
457
+
458
+ it('setFieldError sets a single field error', () => {
459
+ const { result: form, unmount } = mountWith(() =>
460
+ useForm<LoginForm>({
461
+ initialValues: { email: '', password: '' },
462
+ onSubmit: () => {
463
+ /* noop */
464
+ },
465
+ }),
466
+ )
467
+
468
+ form.setFieldError('email', 'Server error: email taken')
469
+ expect(form.fields.email.error()).toBe('Server error: email taken')
470
+ expect(form.isValid()).toBe(false)
471
+
472
+ form.setFieldError('email', undefined)
473
+ expect(form.fields.email.error()).toBeUndefined()
474
+ expect(form.isValid()).toBe(true)
475
+ unmount()
476
+ })
477
+
478
+ it('setErrors sets multiple field errors at once', () => {
479
+ const { result: form, unmount } = mountWith(() =>
480
+ useForm<LoginForm>({
481
+ initialValues: { email: '', password: '' },
482
+ onSubmit: () => {
483
+ /* noop */
484
+ },
485
+ }),
486
+ )
487
+
488
+ form.setErrors({
489
+ email: 'Invalid email',
490
+ password: 'Too weak',
491
+ })
492
+ expect(form.fields.email.error()).toBe('Invalid email')
493
+ expect(form.fields.password.error()).toBe('Too weak')
494
+ expect(form.isValid()).toBe(false)
495
+ unmount()
496
+ })
497
+
498
+ it('debounceMs delays validation', async () => {
499
+ let callCount = 0
500
+ const { result: form, unmount } = mountWith(() =>
501
+ useForm({
502
+ initialValues: { name: '' },
503
+ validators: {
504
+ name: (v) => {
505
+ callCount++
506
+ return !v ? 'Required' : undefined
507
+ },
508
+ },
509
+ onSubmit: () => {
510
+ /* noop */
511
+ },
512
+ validateOn: 'blur',
513
+ debounceMs: 50,
514
+ }),
515
+ )
516
+
517
+ // Trigger multiple rapid blurs
518
+ form.fields.name.setTouched()
519
+ form.fields.name.setTouched()
520
+ form.fields.name.setTouched()
521
+
522
+ // Should not have validated yet
523
+ await new Promise((r) => setTimeout(r, 10))
524
+ expect(callCount).toBe(0)
525
+
526
+ // After debounce period, should have validated once
527
+ await new Promise((r) => setTimeout(r, 60))
528
+ expect(callCount).toBe(1)
529
+ expect(form.fields.name.error()).toBe('Required')
530
+ unmount()
531
+ })
532
+
533
+ it('validate() bypasses debounce for immediate validation', async () => {
534
+ let callCount = 0
535
+ const { result: form, unmount } = mountWith(() =>
536
+ useForm({
537
+ initialValues: { name: '' },
538
+ validators: {
539
+ name: (v) => {
540
+ callCount++
541
+ return !v ? 'Required' : undefined
542
+ },
543
+ },
544
+ onSubmit: () => {
545
+ /* noop */
546
+ },
547
+ debounceMs: 500,
548
+ }),
549
+ )
550
+
551
+ // Direct validate() should run immediately regardless of debounce
552
+ const valid = await form.validate()
553
+ expect(valid).toBe(false)
554
+ expect(callCount).toBe(1)
555
+ unmount()
556
+ })
557
+
558
+ it('setFieldValue sets a field value from the form level', () => {
559
+ const { result: form, unmount } = mountWith(() =>
560
+ useForm<LoginForm>({
561
+ initialValues: { email: '', password: '' },
562
+ onSubmit: () => {
563
+ /* noop */
564
+ },
565
+ }),
566
+ )
567
+
568
+ form.setFieldValue('email', 'new@email.com')
569
+ expect(form.fields.email.value()).toBe('new@email.com')
570
+ expect(form.fields.email.dirty()).toBe(true)
571
+ unmount()
572
+ })
573
+
574
+ it('clearErrors clears all field errors', async () => {
575
+ const { result: form, unmount } = mountWith(() =>
576
+ useForm<LoginForm>({
577
+ initialValues: { email: '', password: '' },
578
+ validators: {
579
+ email: (v) => (!v ? 'Required' : undefined),
580
+ password: (v) => (!v ? 'Required' : undefined),
581
+ },
582
+ onSubmit: () => {
583
+ /* noop */
584
+ },
585
+ }),
586
+ )
587
+
588
+ await form.validate()
589
+ expect(form.isValid()).toBe(false)
590
+
591
+ form.clearErrors()
592
+ expect(form.fields.email.error()).toBeUndefined()
593
+ expect(form.fields.password.error()).toBeUndefined()
594
+ expect(form.isValid()).toBe(true)
595
+ unmount()
596
+ })
597
+
598
+ it('resetField resets a single field without affecting others', () => {
599
+ const { result: form, unmount } = mountWith(() =>
600
+ useForm<LoginForm>({
601
+ initialValues: { email: '', password: '' },
602
+ onSubmit: () => {
603
+ /* noop */
604
+ },
605
+ }),
606
+ )
607
+
608
+ form.fields.email.setValue('changed')
609
+ form.fields.password.setValue('changed')
610
+ form.fields.email.setTouched()
611
+
612
+ form.resetField('email')
613
+ expect(form.fields.email.value()).toBe('')
614
+ expect(form.fields.email.dirty()).toBe(false)
615
+ expect(form.fields.email.touched()).toBe(false)
616
+ // Password should be unaffected
617
+ expect(form.fields.password.value()).toBe('changed')
618
+ expect(form.fields.password.dirty()).toBe(true)
619
+ unmount()
620
+ })
621
+
622
+ it('isValidating tracks async validation state', async () => {
623
+ const { result: form, unmount } = mountWith(() =>
624
+ useForm({
625
+ initialValues: { name: '' },
626
+ validators: {
627
+ name: async (v) => {
628
+ await new Promise((r) => setTimeout(r, 20))
629
+ return !v ? 'Required' : undefined
630
+ },
631
+ },
632
+ onSubmit: () => {
633
+ /* noop */
634
+ },
635
+ }),
636
+ )
637
+
638
+ expect(form.isValidating()).toBe(false)
639
+ const validatePromise = form.validate()
640
+ expect(form.isValidating()).toBe(true)
641
+ await validatePromise
642
+ expect(form.isValidating()).toBe(false)
643
+ unmount()
644
+ })
645
+
646
+ it('handleSubmit calls preventDefault on event', async () => {
647
+ const { result: form, unmount } = mountWith(() =>
648
+ useForm<LoginForm>({
649
+ initialValues: { email: 'a@b.com', password: '12345678' },
650
+ onSubmit: () => {
651
+ /* noop */
652
+ },
653
+ }),
654
+ )
655
+
656
+ let preventDefaultCalled = false
657
+ const fakeEvent = {
658
+ preventDefault: () => {
659
+ preventDefaultCalled = true
660
+ },
661
+ } as unknown as Event
662
+
663
+ await form.handleSubmit(fakeEvent)
664
+ expect(preventDefaultCalled).toBe(true)
665
+ unmount()
666
+ })
667
+
668
+ it('register() with checkbox type uses checked property', () => {
669
+ const { result: form, unmount } = mountWith(() =>
670
+ useForm({
671
+ initialValues: { remember: false },
672
+ onSubmit: () => {
673
+ /* noop */
674
+ },
675
+ }),
676
+ )
677
+
678
+ const props = form.register('remember', { type: 'checkbox' })
679
+ expect(props.checked).toBeDefined()
680
+ expect(props.checked!()).toBe(false)
681
+
682
+ // Simulate checkbox change
683
+ const fakeEvent = {
684
+ target: { checked: true, value: 'on' },
685
+ } as unknown as Event
686
+ props.onInput(fakeEvent)
687
+
688
+ expect(form.fields.remember.value()).toBe(true)
689
+ expect(props.checked!()).toBe(true)
690
+ unmount()
691
+ })
692
+
693
+ it('register() with number type uses valueAsNumber when valid', () => {
694
+ const { result: form, unmount } = mountWith(() =>
695
+ useForm({
696
+ initialValues: { age: 0 },
697
+ onSubmit: () => {
698
+ /* noop */
699
+ },
700
+ }),
701
+ )
702
+
703
+ const props = form.register('age', { type: 'number' })
704
+
705
+ // Simulate input with a valid number
706
+ const validEvent = {
707
+ target: { value: '25', valueAsNumber: 25 },
708
+ } as unknown as Event
709
+ props.onInput(validEvent)
710
+ expect(form.fields.age.value()).toBe(25)
711
+
712
+ // Simulate input with NaN (e.g. empty string) — falls back to target.value
713
+ const nanEvent = {
714
+ target: { value: '', valueAsNumber: NaN },
715
+ } as unknown as Event
716
+ props.onInput(nanEvent)
717
+ expect(form.fields.age.value()).toBe('')
718
+ unmount()
719
+ })
720
+
721
+ it('register() returns same props for repeated calls (memoized)', () => {
722
+ const { result: form, unmount } = mountWith(() =>
723
+ useForm<LoginForm>({
724
+ initialValues: { email: '', password: '' },
725
+ onSubmit: () => {
726
+ /* noop */
727
+ },
728
+ }),
729
+ )
730
+
731
+ const first = form.register('email')
732
+ const second = form.register('email')
733
+ expect(first).toBe(second)
734
+ unmount()
735
+ })
736
+
737
+ it('submitError captures onSubmit errors', async () => {
738
+ const { result: form, unmount } = mountWith(() =>
739
+ useForm<LoginForm>({
740
+ initialValues: { email: 'a@b.com', password: '12345678' },
741
+ onSubmit: async () => {
742
+ throw new Error('Server error')
743
+ },
744
+ }),
745
+ )
746
+
747
+ expect(form.submitError()).toBeUndefined()
748
+ await form.handleSubmit().catch(() => {
749
+ /* expected */
750
+ })
751
+ expect(form.submitError()).toBeInstanceOf(Error)
752
+ expect((form.submitError() as Error).message).toBe('Server error')
753
+
754
+ // Reset clears submitError
755
+ form.reset()
756
+ expect(form.submitError()).toBeUndefined()
757
+ unmount()
758
+ })
759
+
760
+ it('dirty detection works for object field values', () => {
761
+ const { result: form, unmount } = mountWith(() =>
762
+ useForm({
763
+ initialValues: { address: { city: 'NYC', zip: '10001' } },
764
+ onSubmit: () => {
765
+ /* noop */
766
+ },
767
+ }),
768
+ )
769
+
770
+ // Same structure = not dirty
771
+ form.fields.address.setValue({ city: 'NYC', zip: '10001' })
772
+ expect(form.fields.address.dirty()).toBe(false)
773
+
774
+ // Different structure = dirty
775
+ form.fields.address.setValue({ city: 'LA', zip: '90001' })
776
+ expect(form.fields.address.dirty()).toBe(true)
777
+ unmount()
778
+ })
779
+
780
+ it('dirty detection works for array field values', () => {
781
+ const { result: form, unmount } = mountWith(() =>
782
+ useForm({
783
+ initialValues: { tags: ['a', 'b'] },
784
+ onSubmit: () => {
785
+ /* noop */
786
+ },
787
+ }),
788
+ )
789
+
790
+ // Same array = not dirty
791
+ form.fields.tags.setValue(['a', 'b'])
792
+ expect(form.fields.tags.dirty()).toBe(false)
793
+
794
+ // Different array = dirty
795
+ form.fields.tags.setValue(['a', 'b', 'c'])
796
+ expect(form.fields.tags.dirty()).toBe(true)
797
+ unmount()
798
+ })
799
+ })
800
+
801
+ // ─── useFieldArray ───────────────────────────────────────────────────────────
802
+
803
+ describe('useFieldArray', () => {
804
+ it('initializes with provided values', () => {
805
+ const { result: arr, unmount } = mountWith(() =>
806
+ useFieldArray(['a', 'b', 'c']),
807
+ )
808
+
809
+ expect(arr.length()).toBe(3)
810
+ expect(arr.values()).toEqual(['a', 'b', 'c'])
811
+ unmount()
812
+ })
813
+
814
+ it('initializes empty by default', () => {
815
+ const { result: arr, unmount } = mountWith(() => useFieldArray<string>())
816
+
817
+ expect(arr.length()).toBe(0)
818
+ expect(arr.values()).toEqual([])
819
+ unmount()
820
+ })
821
+
822
+ it('append adds to end', () => {
823
+ const { result: arr, unmount } = mountWith(() => useFieldArray(['a']))
824
+
825
+ arr.append('b')
826
+ expect(arr.values()).toEqual(['a', 'b'])
827
+ expect(arr.length()).toBe(2)
828
+ unmount()
829
+ })
830
+
831
+ it('prepend adds to start', () => {
832
+ const { result: arr, unmount } = mountWith(() => useFieldArray(['b']))
833
+
834
+ arr.prepend('a')
835
+ expect(arr.values()).toEqual(['a', 'b'])
836
+ unmount()
837
+ })
838
+
839
+ it('insert at index', () => {
840
+ const { result: arr, unmount } = mountWith(() => useFieldArray(['a', 'c']))
841
+
842
+ arr.insert(1, 'b')
843
+ expect(arr.values()).toEqual(['a', 'b', 'c'])
844
+ unmount()
845
+ })
846
+
847
+ it('remove by index', () => {
848
+ const { result: arr, unmount } = mountWith(() =>
849
+ useFieldArray(['a', 'b', 'c']),
850
+ )
851
+
852
+ arr.remove(1)
853
+ expect(arr.values()).toEqual(['a', 'c'])
854
+ unmount()
855
+ })
856
+
857
+ it('move reorders items', () => {
858
+ const { result: arr, unmount } = mountWith(() =>
859
+ useFieldArray(['a', 'b', 'c']),
860
+ )
861
+
862
+ arr.move(0, 2)
863
+ expect(arr.values()).toEqual(['b', 'c', 'a'])
864
+ unmount()
865
+ })
866
+
867
+ it('swap exchanges items', () => {
868
+ const { result: arr, unmount } = mountWith(() =>
869
+ useFieldArray(['a', 'b', 'c']),
870
+ )
871
+
872
+ arr.swap(0, 2)
873
+ expect(arr.values()).toEqual(['c', 'b', 'a'])
874
+ unmount()
875
+ })
876
+
877
+ it('replace replaces all items', () => {
878
+ const { result: arr, unmount } = mountWith(() => useFieldArray(['a', 'b']))
879
+
880
+ arr.replace(['x', 'y', 'z'])
881
+ expect(arr.values()).toEqual(['x', 'y', 'z'])
882
+ expect(arr.length()).toBe(3)
883
+ unmount()
884
+ })
885
+
886
+ it('items have stable keys', () => {
887
+ const { result: arr, unmount } = mountWith(() => useFieldArray(['a', 'b']))
888
+
889
+ const keysBefore = arr.items().map((i: any) => i.key)
890
+ arr.append('c')
891
+ const keysAfter = arr.items().map((i: any) => i.key)
892
+
893
+ // First two keys should be preserved
894
+ expect(keysAfter[0]).toBe(keysBefore[0])
895
+ expect(keysAfter[1]).toBe(keysBefore[1])
896
+ // New item gets a new key
897
+ expect(keysAfter[2]).not.toBe(keysBefore[0])
898
+ expect(keysAfter[2]).not.toBe(keysBefore[1])
899
+ unmount()
900
+ })
901
+
902
+ it('individual item values are reactive signals', () => {
903
+ const { result: arr, unmount } = mountWith(() => useFieldArray(['a', 'b']))
904
+
905
+ const item = arr.items()[0]!
906
+ expect(item.value()).toBe('a')
907
+ item.value.set('updated')
908
+ expect(item.value()).toBe('updated')
909
+ unmount()
910
+ })
911
+
912
+ it('update modifies value at index', () => {
913
+ const { result: arr, unmount } = mountWith(() =>
914
+ useFieldArray(['a', 'b', 'c']),
915
+ )
916
+
917
+ arr.update(1, 'updated')
918
+ expect(arr.values()).toEqual(['a', 'updated', 'c'])
919
+
920
+ // Key should be preserved
921
+ const item = arr.items()[1]!
922
+ expect(item.value()).toBe('updated')
923
+ unmount()
924
+ })
925
+
926
+ it('update with invalid (out-of-bounds) index is a no-op', () => {
927
+ const { result: arr, unmount } = mountWith(() => useFieldArray(['a', 'b']))
928
+
929
+ arr.update(99, 'nope')
930
+ expect(arr.values()).toEqual(['a', 'b'])
931
+
932
+ arr.update(-1, 'nope')
933
+ expect(arr.values()).toEqual(['a', 'b'])
934
+ unmount()
935
+ })
936
+
937
+ it('move with invalid from index does not insert undefined', () => {
938
+ const { result: arr, unmount } = mountWith(() =>
939
+ useFieldArray(['a', 'b', 'c']),
940
+ )
941
+
942
+ // splice(99,1) returns [] so item is undefined → splice(to,0) is a no-op
943
+ arr.move(99, 0)
944
+ // The array should still have 3 items (the splice removed nothing, guard prevented insert)
945
+ // Actually splice removes nothing and returns [], item is undefined so nothing inserted
946
+ expect(arr.values()).toEqual(['a', 'b', 'c'])
947
+ unmount()
948
+ })
949
+
950
+ it('swap with one invalid index is a no-op', () => {
951
+ const { result: arr, unmount } = mountWith(() =>
952
+ useFieldArray(['a', 'b', 'c']),
953
+ )
954
+
955
+ // indexA out of bounds
956
+ arr.swap(99, 0)
957
+ expect(arr.values()).toEqual(['a', 'b', 'c'])
958
+
959
+ // indexB out of bounds
960
+ arr.swap(0, 99)
961
+ expect(arr.values()).toEqual(['a', 'b', 'c'])
962
+
963
+ // Both out of bounds
964
+ arr.swap(99, 100)
965
+ expect(arr.values()).toEqual(['a', 'b', 'c'])
966
+ unmount()
967
+ })
968
+ })
969
+
970
+ // ─── structuredEqual (via dirty tracking) ────────────────────────────────────
971
+
972
+ describe('structuredEqual coverage via dirty tracking', () => {
973
+ it('arrays with different lengths are detected as dirty', () => {
974
+ const { result: form, unmount } = mountWith(() =>
975
+ useForm({
976
+ initialValues: { items: ['a', 'b'] as string[] },
977
+ onSubmit: () => {
978
+ /* noop */
979
+ },
980
+ }),
981
+ )
982
+
983
+ // Longer array
984
+ form.fields.items.setValue(['a', 'b', 'c'])
985
+ expect(form.fields.items.dirty()).toBe(true)
986
+
987
+ // Shorter array
988
+ form.fields.items.setValue(['a'])
989
+ expect(form.fields.items.dirty()).toBe(true)
990
+
991
+ // Same length same elements — not dirty
992
+ form.fields.items.setValue(['a', 'b'])
993
+ expect(form.fields.items.dirty()).toBe(false)
994
+ unmount()
995
+ })
996
+
997
+ it('arrays with same length but different elements are detected as dirty', () => {
998
+ const { result: form, unmount } = mountWith(() =>
999
+ useForm({
1000
+ initialValues: { items: ['a', 'b', 'c'] as string[] },
1001
+ onSubmit: () => {
1002
+ /* noop */
1003
+ },
1004
+ }),
1005
+ )
1006
+
1007
+ form.fields.items.setValue(['a', 'b', 'x'])
1008
+ expect(form.fields.items.dirty()).toBe(true)
1009
+
1010
+ form.fields.items.setValue(['x', 'b', 'c'])
1011
+ expect(form.fields.items.dirty()).toBe(true)
1012
+
1013
+ // Restoring original clears dirty
1014
+ form.fields.items.setValue(['a', 'b', 'c'])
1015
+ expect(form.fields.items.dirty()).toBe(false)
1016
+ unmount()
1017
+ })
1018
+
1019
+ it('objects with different number of keys are detected as dirty', () => {
1020
+ const { result: form, unmount } = mountWith(() =>
1021
+ useForm({
1022
+ initialValues: { meta: { x: 1, y: 2 } as Record<string, number> },
1023
+ onSubmit: () => {
1024
+ /* noop */
1025
+ },
1026
+ }),
1027
+ )
1028
+
1029
+ // More keys
1030
+ form.fields.meta.setValue({ x: 1, y: 2, z: 3 })
1031
+ expect(form.fields.meta.dirty()).toBe(true)
1032
+
1033
+ // Fewer keys
1034
+ form.fields.meta.setValue({ x: 1 })
1035
+ expect(form.fields.meta.dirty()).toBe(true)
1036
+
1037
+ // Same keys same values — not dirty
1038
+ form.fields.meta.setValue({ x: 1, y: 2 })
1039
+ expect(form.fields.meta.dirty()).toBe(false)
1040
+ unmount()
1041
+ })
1042
+
1043
+ it('objects with same key count but different values are detected as dirty', () => {
1044
+ const { result: form, unmount } = mountWith(() =>
1045
+ useForm({
1046
+ initialValues: { meta: { x: 1, y: 2 } as Record<string, number> },
1047
+ onSubmit: () => {
1048
+ /* noop */
1049
+ },
1050
+ }),
1051
+ )
1052
+
1053
+ form.fields.meta.setValue({ x: 1, y: 999 })
1054
+ expect(form.fields.meta.dirty()).toBe(true)
1055
+ unmount()
1056
+ })
1057
+
1058
+ it('null vs object is detected as dirty', () => {
1059
+ const { result: form, unmount } = mountWith(() =>
1060
+ useForm({
1061
+ initialValues: { data: { a: 1 } as Record<string, number> | null },
1062
+ onSubmit: () => {
1063
+ /* noop */
1064
+ },
1065
+ }),
1066
+ )
1067
+
1068
+ form.fields.data.setValue(null)
1069
+ expect(form.fields.data.dirty()).toBe(true)
1070
+ unmount()
1071
+ })
1072
+ })
1073
+
1074
+ // ─── validateOn: 'submit' ───────────────────────────────────────────────────
1075
+
1076
+ describe('validateOn: submit', () => {
1077
+ it('does not validate on blur', async () => {
1078
+ let validatorCalls = 0
1079
+ const { result: form, unmount } = mountWith(() =>
1080
+ useForm({
1081
+ initialValues: { name: '' },
1082
+ validators: {
1083
+ name: (v) => {
1084
+ validatorCalls++
1085
+ return !v ? 'Required' : undefined
1086
+ },
1087
+ },
1088
+ onSubmit: () => {
1089
+ /* noop */
1090
+ },
1091
+ validateOn: 'submit',
1092
+ }),
1093
+ )
1094
+
1095
+ // Blur should NOT trigger validation
1096
+ form.fields.name.setTouched()
1097
+ await new Promise((r) => setTimeout(r, 10))
1098
+ expect(validatorCalls).toBe(0)
1099
+ expect(form.fields.name.error()).toBeUndefined()
1100
+
1101
+ // setValue should NOT trigger validation
1102
+ form.fields.name.setValue('hello')
1103
+ await new Promise((r) => setTimeout(r, 10))
1104
+ expect(validatorCalls).toBe(0)
1105
+ expect(form.fields.name.error()).toBeUndefined()
1106
+ unmount()
1107
+ })
1108
+
1109
+ it('validates only when handleSubmit is called', async () => {
1110
+ let submitted = false
1111
+ const { result: form, unmount } = mountWith(() =>
1112
+ useForm({
1113
+ initialValues: { name: '' },
1114
+ validators: {
1115
+ name: (v) => (!v ? 'Required' : undefined),
1116
+ },
1117
+ onSubmit: () => {
1118
+ submitted = true
1119
+ },
1120
+ validateOn: 'submit',
1121
+ }),
1122
+ )
1123
+
1124
+ // No validation until submit
1125
+ form.fields.name.setTouched()
1126
+ await new Promise((r) => setTimeout(r, 10))
1127
+ expect(form.fields.name.error()).toBeUndefined()
1128
+
1129
+ // Submit triggers validation
1130
+ await form.handleSubmit()
1131
+ expect(submitted).toBe(false)
1132
+ expect(form.fields.name.error()).toBe('Required')
1133
+
1134
+ // Fix and resubmit
1135
+ form.fields.name.setValue('hello')
1136
+ await form.handleSubmit()
1137
+ expect(submitted).toBe(true)
1138
+ unmount()
1139
+ })
1140
+ })
1141
+
1142
+ // ─── debounceMs for field validation ─────────────────────────────────────────
1143
+
1144
+ describe('debounceMs field validation', () => {
1145
+ it('debounced validation on change mode', async () => {
1146
+ let callCount = 0
1147
+ const { result: form, unmount } = mountWith(() =>
1148
+ useForm({
1149
+ initialValues: { name: '' },
1150
+ validators: {
1151
+ name: (v) => {
1152
+ callCount++
1153
+ return !v ? 'Required' : undefined
1154
+ },
1155
+ },
1156
+ onSubmit: () => {
1157
+ /* noop */
1158
+ },
1159
+ validateOn: 'change',
1160
+ debounceMs: 50,
1161
+ }),
1162
+ )
1163
+
1164
+ // Change should trigger debounced validation
1165
+ form.fields.name.setValue('a')
1166
+ form.fields.name.setValue('ab')
1167
+ form.fields.name.setValue('abc')
1168
+
1169
+ // Not yet validated
1170
+ await new Promise((r) => setTimeout(r, 10))
1171
+ expect(callCount).toBe(0)
1172
+
1173
+ // After debounce, should validate
1174
+ await new Promise((r) => setTimeout(r, 80))
1175
+ expect(callCount).toBeGreaterThanOrEqual(1)
1176
+ expect(form.fields.name.error()).toBeUndefined()
1177
+ unmount()
1178
+ })
1179
+
1180
+ it('debounced validation resolves after timer fires', async () => {
1181
+ const { result: form, unmount } = mountWith(() =>
1182
+ useForm({
1183
+ initialValues: { name: '' },
1184
+ validators: {
1185
+ name: (v) => (!v ? 'Required' : undefined),
1186
+ },
1187
+ onSubmit: () => {
1188
+ /* noop */
1189
+ },
1190
+ validateOn: 'blur',
1191
+ debounceMs: 30,
1192
+ }),
1193
+ )
1194
+
1195
+ form.fields.name.setTouched()
1196
+
1197
+ // Before debounce fires
1198
+ await new Promise((r) => setTimeout(r, 5))
1199
+ expect(form.fields.name.error()).toBeUndefined()
1200
+
1201
+ // After debounce fires
1202
+ await new Promise((r) => setTimeout(r, 50))
1203
+ expect(form.fields.name.error()).toBe('Required')
1204
+ unmount()
1205
+ })
1206
+
1207
+ it('reset clears pending debounce timers', async () => {
1208
+ let callCount = 0
1209
+ const { result: form, unmount } = mountWith(() =>
1210
+ useForm({
1211
+ initialValues: { name: '' },
1212
+ validators: {
1213
+ name: (v) => {
1214
+ callCount++
1215
+ return !v ? 'Required' : undefined
1216
+ },
1217
+ },
1218
+ onSubmit: () => {
1219
+ /* noop */
1220
+ },
1221
+ validateOn: 'blur',
1222
+ debounceMs: 50,
1223
+ }),
1224
+ )
1225
+
1226
+ form.fields.name.setTouched()
1227
+ // Reset before debounce fires
1228
+ form.reset()
1229
+
1230
+ await new Promise((r) => setTimeout(r, 80))
1231
+ // Validator should not have been called since timer was cleared
1232
+ expect(callCount).toBe(0)
1233
+ expect(form.fields.name.error()).toBeUndefined()
1234
+ unmount()
1235
+ })
1236
+ })
1237
+
1238
+ // ─── Edge case: nonexistent field names ──────────────────────────────────────
1239
+
1240
+ describe('useForm nonexistent field operations', () => {
1241
+ it('setFieldValue with nonexistent field throws', () => {
1242
+ const { result: form, unmount } = mountWith(() =>
1243
+ useForm({
1244
+ initialValues: { name: 'Alice' },
1245
+ onSubmit: () => {
1246
+ /* noop */
1247
+ },
1248
+ }),
1249
+ )
1250
+
1251
+ expect(() => form.setFieldValue('nonexistent' as any, 'value')).toThrow(
1252
+ '[@pyreon/form] Field "nonexistent" does not exist',
1253
+ )
1254
+ expect(form.fields.name.value()).toBe('Alice')
1255
+ unmount()
1256
+ })
1257
+
1258
+ it('setFieldError with nonexistent field throws', () => {
1259
+ const { result: form, unmount } = mountWith(() =>
1260
+ useForm({
1261
+ initialValues: { name: 'Alice' },
1262
+ onSubmit: () => {
1263
+ /* noop */
1264
+ },
1265
+ }),
1266
+ )
1267
+
1268
+ expect(() => form.setFieldError('nonexistent' as any, 'error')).toThrow(
1269
+ '[@pyreon/form] Field "nonexistent" does not exist',
1270
+ )
1271
+ expect(form.isValid()).toBe(true)
1272
+ unmount()
1273
+ })
1274
+
1275
+ it('resetField with nonexistent field is a no-op', () => {
1276
+ const { result: form, unmount } = mountWith(() =>
1277
+ useForm({
1278
+ initialValues: { name: 'Alice' },
1279
+ onSubmit: () => {
1280
+ /* noop */
1281
+ },
1282
+ }),
1283
+ )
1284
+
1285
+ form.resetField('nonexistent' as any)
1286
+ expect(form.fields.name.value()).toBe('Alice')
1287
+ unmount()
1288
+ })
1289
+ })
1290
+
1291
+ // ─── Edge case: structuredEqual mixed types ──────────────────────────────────
1292
+
1293
+ describe('dirty detection with mixed types', () => {
1294
+ it('number vs string is dirty', () => {
1295
+ const { result: form, unmount } = mountWith(() =>
1296
+ useForm({
1297
+ initialValues: { value: 0 as any },
1298
+ onSubmit: () => {
1299
+ /* noop */
1300
+ },
1301
+ }),
1302
+ )
1303
+
1304
+ form.fields.value.setValue('0' as any)
1305
+ expect(form.fields.value.dirty()).toBe(true)
1306
+ unmount()
1307
+ })
1308
+ })
1309
+
1310
+ // ─── validate() branch coverage ──────────────────────────────────────────────
1311
+
1312
+ describe('validate() branch coverage', () => {
1313
+ it('getErrors returns empty when no errors exist', async () => {
1314
+ const { result: form, unmount } = mountWith(() =>
1315
+ useForm({
1316
+ initialValues: { name: 'valid', email: 'a@b.com' },
1317
+ validators: {
1318
+ name: (v) => (!v ? 'Required' : undefined),
1319
+ email: (v) => (!v ? 'Required' : undefined),
1320
+ },
1321
+ onSubmit: () => {
1322
+ /* noop */
1323
+ },
1324
+ }),
1325
+ )
1326
+
1327
+ // Validate with all valid values — errors() should return empty object
1328
+ await form.validate()
1329
+ expect(form.errors()).toEqual({})
1330
+ unmount()
1331
+ })
1332
+
1333
+ it('stale async field-level validation on blur is discarded', async () => {
1334
+ const resolvers: Array<(v: string | undefined) => void> = []
1335
+ const { result: form, unmount } = mountWith(() =>
1336
+ useForm({
1337
+ initialValues: { name: '' },
1338
+ validators: {
1339
+ name: (_v) => {
1340
+ return new Promise<string | undefined>((resolve) => {
1341
+ resolvers.push(resolve)
1342
+ })
1343
+ },
1344
+ },
1345
+ onSubmit: () => {
1346
+ /* noop */
1347
+ },
1348
+ validateOn: 'blur',
1349
+ }),
1350
+ )
1351
+
1352
+ // Trigger first blur validation
1353
+ form.fields.name.setTouched()
1354
+ // Trigger second blur validation (bumps version, makes first stale)
1355
+ form.fields.name.setTouched()
1356
+
1357
+ // Resolve the first (stale) result
1358
+ resolvers[0]!('Stale error from blur')
1359
+ await new Promise((r) => setTimeout(r, 0))
1360
+ // Error should NOT be set since it's stale
1361
+ expect(form.fields.name.error()).toBeUndefined()
1362
+
1363
+ // Resolve the second (current) result
1364
+ resolvers[1]!(undefined)
1365
+ await new Promise((r) => setTimeout(r, 0))
1366
+ expect(form.fields.name.error()).toBeUndefined()
1367
+ unmount()
1368
+ })
1369
+
1370
+ it('stale async validation results are discarded during validate()', async () => {
1371
+ const resolvers: Array<(v: string | undefined) => void> = []
1372
+ const { result: form, unmount } = mountWith(() =>
1373
+ useForm({
1374
+ initialValues: { name: '' },
1375
+ validators: {
1376
+ name: (_v) => {
1377
+ return new Promise<string | undefined>((resolve) => {
1378
+ resolvers.push(resolve)
1379
+ })
1380
+ },
1381
+ },
1382
+ onSubmit: () => {
1383
+ /* noop */
1384
+ },
1385
+ }),
1386
+ )
1387
+
1388
+ // Start first validation
1389
+ const firstValidate = form.validate()
1390
+ // Start second validation before first resolves — bumps version
1391
+ const secondValidate = form.validate()
1392
+
1393
+ // Resolve the first (stale) validation — should be discarded
1394
+ resolvers[0]!('Stale error')
1395
+ // Resolve the second (current) validation
1396
+ resolvers[1]!(undefined)
1397
+
1398
+ await Promise.all([firstValidate, secondValidate])
1399
+
1400
+ // The stale error should NOT have been applied
1401
+ expect(form.fields.name.error()).toBeUndefined()
1402
+ unmount()
1403
+ })
1404
+
1405
+ it('field-level validator throwing during validate() captures error', async () => {
1406
+ const { result: form, unmount } = mountWith(() =>
1407
+ useForm({
1408
+ initialValues: { name: 'Alice' },
1409
+ validators: {
1410
+ name: () => {
1411
+ throw new Error('Validator crashed')
1412
+ },
1413
+ },
1414
+ onSubmit: () => {
1415
+ /* noop */
1416
+ },
1417
+ }),
1418
+ )
1419
+
1420
+ const valid = await form.validate()
1421
+ expect(valid).toBe(false)
1422
+ expect(form.fields.name.error()).toBe('Validator crashed')
1423
+ unmount()
1424
+ })
1425
+
1426
+ it('field-level validator throwing on blur captures error', async () => {
1427
+ const { result: form, unmount } = mountWith(() =>
1428
+ useForm({
1429
+ initialValues: { name: '' },
1430
+ validators: {
1431
+ name: () => {
1432
+ throw new Error('Blur validator crashed')
1433
+ },
1434
+ },
1435
+ validateOn: 'blur',
1436
+ onSubmit: () => {
1437
+ /* noop */
1438
+ },
1439
+ }),
1440
+ )
1441
+
1442
+ form.fields.name.setTouched()
1443
+ await new Promise((r) => setTimeout(r, 0))
1444
+ expect(form.fields.name.error()).toBe('Blur validator crashed')
1445
+ unmount()
1446
+ })
1447
+
1448
+ it('schema validator with keys having undefined value does not block submit', async () => {
1449
+ let submitted = false
1450
+ const { result: form, unmount } = mountWith(() =>
1451
+ useForm({
1452
+ initialValues: { name: 'Alice', email: 'a@b.com' },
1453
+ schema: (_values) => {
1454
+ // Return an object where some keys have undefined values
1455
+ return { name: undefined, email: undefined } as any
1456
+ },
1457
+ onSubmit: () => {
1458
+ submitted = true
1459
+ },
1460
+ }),
1461
+ )
1462
+
1463
+ await form.handleSubmit()
1464
+ // Schema returned keys but all with undefined values — should pass
1465
+ expect(submitted).toBe(true)
1466
+ unmount()
1467
+ })
1468
+
1469
+ it('schema validator throwing sets submitError and returns false', async () => {
1470
+ const { result: form, unmount } = mountWith(() =>
1471
+ useForm({
1472
+ initialValues: { name: 'Alice', email: 'a@b.com' },
1473
+ schema: () => {
1474
+ throw new Error('Schema exploded')
1475
+ },
1476
+ onSubmit: () => {
1477
+ /* noop */
1478
+ },
1479
+ }),
1480
+ )
1481
+
1482
+ const valid = await form.validate()
1483
+ expect(valid).toBe(false)
1484
+ expect(form.submitError()).toBeInstanceOf(Error)
1485
+ expect((form.submitError() as Error).message).toBe('Schema exploded')
1486
+ unmount()
1487
+ })
1488
+
1489
+ it('fields without validators return undefined in validate()', async () => {
1490
+ const { result: form, unmount } = mountWith(() =>
1491
+ useForm({
1492
+ initialValues: { name: 'Alice', noValidator: 'test' },
1493
+ validators: {
1494
+ name: (v) => (!v ? 'Required' : undefined),
1495
+ // noValidator field has no validator
1496
+ },
1497
+ onSubmit: () => {
1498
+ /* noop */
1499
+ },
1500
+ }),
1501
+ )
1502
+
1503
+ const valid = await form.validate()
1504
+ expect(valid).toBe(true)
1505
+ expect(form.fields.noValidator.error()).toBeUndefined()
1506
+ unmount()
1507
+ })
1508
+ })
1509
+
1510
+ // ─── Edge case: debounceMs + validateOn: 'change' ───────────────────────────
1511
+
1512
+ describe('debounceMs with validateOn change', () => {
1513
+ it('debounces validation on change', async () => {
1514
+ let callCount = 0
1515
+ const { result: form, unmount } = mountWith(() =>
1516
+ useForm({
1517
+ initialValues: { name: '' },
1518
+ validators: {
1519
+ name: async (v) => {
1520
+ callCount++
1521
+ return v.length < 3 ? 'Too short' : undefined
1522
+ },
1523
+ },
1524
+ validateOn: 'change',
1525
+ debounceMs: 50,
1526
+ onSubmit: () => {
1527
+ /* noop */
1528
+ },
1529
+ }),
1530
+ )
1531
+
1532
+ form.fields.name.setValue('a')
1533
+ form.fields.name.setValue('ab')
1534
+ form.fields.name.setValue('abc')
1535
+
1536
+ // None should have fired yet
1537
+ expect(callCount).toBe(0)
1538
+
1539
+ await new Promise((r) => setTimeout(r, 80))
1540
+ // Only the last one should have fired after debounce
1541
+ expect(callCount).toBe(1)
1542
+ expect(form.fields.name.error()).toBeUndefined()
1543
+ unmount()
1544
+ })
1545
+ })
1546
+
1547
+ // ─── useField ────────────────────────────────────────────────────────────────
1548
+
1549
+ describe('useField', () => {
1550
+ it('extracts a single field from a form', () => {
1551
+ const { result, unmount } = mountWith(() => {
1552
+ const form = useForm({
1553
+ initialValues: { email: '', password: '' },
1554
+ onSubmit: () => {
1555
+ /* noop */
1556
+ },
1557
+ })
1558
+ const field = useField(form, 'email')
1559
+ return { form, field }
1560
+ })
1561
+
1562
+ expect(result.field.value()).toBe('')
1563
+ result.field.setValue('test@test.com')
1564
+ expect(result.form.fields.email.value()).toBe('test@test.com')
1565
+ expect(result.field.dirty()).toBe(true)
1566
+ unmount()
1567
+ })
1568
+
1569
+ it('hasError and showError computed correctly', async () => {
1570
+ const { result, unmount } = mountWith(() => {
1571
+ const form = useForm({
1572
+ initialValues: { email: '' },
1573
+ validators: { email: (v) => (!v ? 'Required' : undefined) },
1574
+ onSubmit: () => {
1575
+ /* noop */
1576
+ },
1577
+ })
1578
+ const field = useField(form, 'email')
1579
+ return { form, field }
1580
+ })
1581
+
1582
+ expect(result.field.hasError()).toBe(false)
1583
+ expect(result.field.showError()).toBe(false)
1584
+
1585
+ // Trigger validation
1586
+ result.field.setTouched()
1587
+ await new Promise((r) => setTimeout(r, 0))
1588
+
1589
+ expect(result.field.hasError()).toBe(true)
1590
+ // showError = touched AND hasError
1591
+ expect(result.field.showError()).toBe(true)
1592
+ unmount()
1593
+ })
1594
+
1595
+ it('register() delegates to form.register()', () => {
1596
+ const { result, unmount } = mountWith(() => {
1597
+ const form = useForm({
1598
+ initialValues: { email: '' },
1599
+ onSubmit: () => {
1600
+ /* noop */
1601
+ },
1602
+ })
1603
+ const field = useField(form, 'email')
1604
+ return { form, field }
1605
+ })
1606
+
1607
+ const fieldProps = result.field.register()
1608
+ const formProps = result.form.register('email')
1609
+ // Should be the same memoized object
1610
+ expect(fieldProps).toBe(formProps)
1611
+ unmount()
1612
+ })
1613
+
1614
+ it('register() with checkbox type', () => {
1615
+ const { result, unmount } = mountWith(() => {
1616
+ const form = useForm({
1617
+ initialValues: { remember: false },
1618
+ onSubmit: () => {
1619
+ /* noop */
1620
+ },
1621
+ })
1622
+ const field = useField(form, 'remember')
1623
+ return { form, field }
1624
+ })
1625
+
1626
+ const props = result.field.register({ type: 'checkbox' })
1627
+ expect(props.checked).toBeDefined()
1628
+ expect(props.checked!()).toBe(false)
1629
+ unmount()
1630
+ })
1631
+
1632
+ it('reset delegates to field reset', () => {
1633
+ const { result, unmount } = mountWith(() => {
1634
+ const form = useForm({
1635
+ initialValues: { name: 'initial' },
1636
+ onSubmit: () => {
1637
+ /* noop */
1638
+ },
1639
+ })
1640
+ const field = useField(form, 'name')
1641
+ return { form, field }
1642
+ })
1643
+
1644
+ result.field.setValue('changed')
1645
+ result.field.setTouched()
1646
+ expect(result.field.dirty()).toBe(true)
1647
+ expect(result.field.touched()).toBe(true)
1648
+
1649
+ result.field.reset()
1650
+ expect(result.field.value()).toBe('initial')
1651
+ expect(result.field.dirty()).toBe(false)
1652
+ expect(result.field.touched()).toBe(false)
1653
+ unmount()
1654
+ })
1655
+ })
1656
+
1657
+ // ─── useWatch ────────────────────────────────────────────────────────────────
1658
+
1659
+ describe('useWatch', () => {
1660
+ it('watches a single field value', () => {
1661
+ const { result, unmount } = mountWith(() => {
1662
+ const form = useForm({
1663
+ initialValues: { email: 'a@b.com', password: '' },
1664
+ onSubmit: () => {
1665
+ /* noop */
1666
+ },
1667
+ })
1668
+ const email = useWatch(form, 'email')
1669
+ return { form, email }
1670
+ })
1671
+
1672
+ expect(result.email()).toBe('a@b.com')
1673
+ result.form.fields.email.setValue('new@email.com')
1674
+ expect(result.email()).toBe('new@email.com')
1675
+ unmount()
1676
+ })
1677
+
1678
+ it('watches multiple fields', () => {
1679
+ const { result, unmount } = mountWith(() => {
1680
+ const form = useForm({
1681
+ initialValues: { first: 'John', last: 'Doe' },
1682
+ onSubmit: () => {
1683
+ /* noop */
1684
+ },
1685
+ })
1686
+ const [first, last] = useWatch(form, ['first', 'last'])
1687
+ return { form, first, last }
1688
+ })
1689
+
1690
+ expect(result.first!()).toBe('John')
1691
+ expect(result.last!()).toBe('Doe')
1692
+ result.form.fields.first.setValue('Jane')
1693
+ expect(result.first!()).toBe('Jane')
1694
+ unmount()
1695
+ })
1696
+
1697
+ it('watches all fields when no name provided', () => {
1698
+ const { result, unmount } = mountWith(() => {
1699
+ const form = useForm({
1700
+ initialValues: { email: 'a@b.com', name: 'Alice' },
1701
+ onSubmit: () => {
1702
+ /* noop */
1703
+ },
1704
+ })
1705
+ const all = useWatch(form)
1706
+ return { form, all }
1707
+ })
1708
+
1709
+ expect(result.all()).toEqual({ email: 'a@b.com', name: 'Alice' })
1710
+ result.form.fields.email.setValue('new@email.com')
1711
+ expect(result.all()).toEqual({ email: 'new@email.com', name: 'Alice' })
1712
+ unmount()
1713
+ })
1714
+ })
1715
+
1716
+ // ─── useFormState ────────────────────────────────────────────────────────────
1717
+
1718
+ describe('useFormState', () => {
1719
+ it('returns full form state summary', async () => {
1720
+ const { result, unmount } = mountWith(() => {
1721
+ const form = useForm({
1722
+ initialValues: { email: '', password: '' },
1723
+ validators: { email: (v) => (!v ? 'Required' : undefined) },
1724
+ onSubmit: () => {
1725
+ /* noop */
1726
+ },
1727
+ })
1728
+ const state = useFormState(form)
1729
+ return { form, state }
1730
+ })
1731
+
1732
+ const s = result.state()
1733
+ expect(s.isSubmitting).toBe(false)
1734
+ expect(s.isValidating).toBe(false)
1735
+ expect(s.isValid).toBe(true)
1736
+ expect(s.isDirty).toBe(false)
1737
+ expect(s.submitCount).toBe(0)
1738
+ expect(s.submitError).toBeUndefined()
1739
+ expect(s.touchedFields).toEqual({})
1740
+ expect(s.dirtyFields).toEqual({})
1741
+ expect(s.errors).toEqual({})
1742
+
1743
+ // Change form state
1744
+ result.form.fields.email.setValue('test')
1745
+ result.form.fields.email.setTouched()
1746
+ await new Promise((r) => setTimeout(r, 0))
1747
+
1748
+ const s2 = result.state()
1749
+ expect(s2.isDirty).toBe(true)
1750
+ expect(s2.dirtyFields).toEqual({ email: true })
1751
+ expect(s2.touchedFields).toEqual({ email: true })
1752
+ unmount()
1753
+ })
1754
+
1755
+ it('works with selector for fine-grained reactivity', async () => {
1756
+ const { result, unmount } = mountWith(() => {
1757
+ const form = useForm({
1758
+ initialValues: { email: '' },
1759
+ validators: { email: (v) => (!v ? 'Required' : undefined) },
1760
+ onSubmit: () => {
1761
+ /* noop */
1762
+ },
1763
+ })
1764
+ const canSubmit = useFormState(form, (s) => s.isValid && !s.isSubmitting)
1765
+ return { form, canSubmit }
1766
+ })
1767
+
1768
+ expect(result.canSubmit()).toBe(true)
1769
+
1770
+ // Trigger validation to make it invalid
1771
+ await result.form.validate()
1772
+ expect(result.canSubmit()).toBe(false)
1773
+
1774
+ // Fix value
1775
+ result.form.fields.email.setValue('test@test.com')
1776
+ await result.form.validate()
1777
+ expect(result.canSubmit()).toBe(true)
1778
+ unmount()
1779
+ })
1780
+
1781
+ it('tracks errors in summary', async () => {
1782
+ const { result, unmount } = mountWith(() => {
1783
+ const form = useForm({
1784
+ initialValues: { email: '', name: '' },
1785
+ validators: {
1786
+ email: (v) => (!v ? 'Email required' : undefined),
1787
+ name: (v) => (!v ? 'Name required' : undefined),
1788
+ },
1789
+ onSubmit: () => {
1790
+ /* noop */
1791
+ },
1792
+ })
1793
+ const state = useFormState(form)
1794
+ return { form, state }
1795
+ })
1796
+
1797
+ await result.form.validate()
1798
+ const s = result.state()
1799
+ expect(s.errors).toEqual({ email: 'Email required', name: 'Name required' })
1800
+ expect(s.isValid).toBe(false)
1801
+ unmount()
1802
+ })
1803
+ })
1804
+
1805
+ // ─── FormProvider / useFormContext ────────────────────────────────────────────
1806
+
1807
+ describe('FormProvider / useFormContext', () => {
1808
+ it('provides form through context', () => {
1809
+ let contextForm: FormState<{ email: string }> | undefined
1810
+ const el = document.createElement('div')
1811
+ document.body.appendChild(el)
1812
+
1813
+ const unmount = mount(
1814
+ h(() => {
1815
+ const form = useForm({
1816
+ initialValues: { email: 'context@test.com' },
1817
+ onSubmit: () => {
1818
+ /* noop */
1819
+ },
1820
+ })
1821
+ return h(FormProvider as any, { form }, () =>
1822
+ h(() => {
1823
+ contextForm = useFormContext<{ email: string }>()
1824
+ return null
1825
+ }, null),
1826
+ )
1827
+ }, null),
1828
+ el,
1829
+ )
1830
+
1831
+ expect(contextForm).toBeDefined()
1832
+ expect(contextForm!.fields.email.value()).toBe('context@test.com')
1833
+ unmount()
1834
+ el.remove()
1835
+ })
1836
+
1837
+ it('throws when useFormContext is called outside FormProvider', () => {
1838
+ const el = document.createElement('div')
1839
+ document.body.appendChild(el)
1840
+
1841
+ let error: Error | undefined
1842
+ const unmount = mount(
1843
+ h(() => {
1844
+ try {
1845
+ useFormContext()
1846
+ } catch (e) {
1847
+ error = e as Error
1848
+ }
1849
+ return null
1850
+ }, null),
1851
+ el,
1852
+ )
1853
+
1854
+ expect(error).toBeDefined()
1855
+ expect(error!.message).toContain('useFormContext')
1856
+ expect(error!.message).toContain('FormProvider')
1857
+ unmount()
1858
+ el.remove()
1859
+ })
1860
+ })