@tanstack/form-core 0.7.2 → 0.8.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/src/FormApi.ts CHANGED
@@ -21,6 +21,7 @@ type ValidateAsyncFn<TData, ValidatorType> = (
21
21
  export type FormOptions<TData, ValidatorType> = {
22
22
  defaultValues?: TData
23
23
  defaultState?: Partial<FormState<TData>>
24
+ asyncAlways?: boolean
24
25
  asyncDebounceMs?: number
25
26
  validator?: ValidatorType
26
27
  onMount?: ValidateOrFn<TData, ValidatorType>
@@ -47,8 +48,8 @@ export type FieldInfo<TFormData, ValidatorType> = {
47
48
  export type ValidationMeta = {
48
49
  validationCount?: number
49
50
  validationAsyncCount?: number
50
- validationPromise?: Promise<ValidationError[]>
51
- validationResolve?: (errors: ValidationError[]) => void
51
+ validationPromise?: Promise<ValidationError[] | undefined>
52
+ validationResolve?: (errors: ValidationError[] | undefined) => void
52
53
  validationReject?: (errors: unknown) => void
53
54
  }
54
55
 
@@ -64,7 +65,8 @@ export type FormState<TData> = {
64
65
  isFormValidating: boolean
65
66
  formValidationCount: number
66
67
  isFormValid: boolean
67
- formError?: ValidationError
68
+ errors: ValidationError[]
69
+ errorMap: ValidationErrorMap
68
70
  // Fields
69
71
  fieldMeta: Record<DeepKeys<TData>, FieldMeta>
70
72
  isFieldsValidating: boolean
@@ -84,6 +86,8 @@ function getDefaultFormState<TData>(
84
86
  ): FormState<TData> {
85
87
  return {
86
88
  values: defaultState.values ?? ({} as never),
89
+ errors: defaultState.errors ?? [],
90
+ errorMap: defaultState.errorMap ?? {},
87
91
  fieldMeta: defaultState.fieldMeta ?? ({} as never),
88
92
  canSubmit: defaultState.canSubmit ?? true,
89
93
  isFieldsValid: defaultState.isFieldsValid ?? false,
@@ -141,7 +145,10 @@ export class FormApi<TFormData, ValidatorType> {
141
145
  const isTouched = fieldMetaValues.some((field) => field?.isTouched)
142
146
 
143
147
  const isValidating = isFieldsValidating || state.isFormValidating
144
- const isFormValid = !state.formError
148
+ state.errors = Object.values(state.errorMap).filter(
149
+ (val: unknown) => val !== undefined,
150
+ )
151
+ const isFormValid = state.errors.length === 0
145
152
  const isValid = isFieldsValid && isFormValid
146
153
  const canSubmit =
147
154
  (state.submissionAttempts === 0 && !isTouched) ||
@@ -169,15 +176,29 @@ export class FormApi<TFormData, ValidatorType> {
169
176
  }
170
177
 
171
178
  mount = () => {
172
- if (typeof this.options.onMount === 'function') {
173
- return this.options.onMount(this.state.values, this)
174
- }
175
- if (this.options.validator) {
176
- return (this.options.validator as Validator<TFormData>)().validate(
179
+ const doValidate = () => {
180
+ if (
181
+ this.options.validator &&
182
+ typeof this.options.onMount !== 'function'
183
+ ) {
184
+ return (this.options.validator as Validator<TFormData>)().validate(
185
+ this.state.values,
186
+ this.options.onMount,
187
+ )
188
+ }
189
+ return (this.options.onMount as ValidateFn<TFormData, ValidatorType>)(
177
190
  this.state.values,
178
- this.options.onMount,
191
+ this,
179
192
  )
180
193
  }
194
+ if (!this.options.onMount) return
195
+ const error = doValidate()
196
+ if (error) {
197
+ this.store.setState((prev) => ({
198
+ ...prev,
199
+ errorMap: { ...prev.errorMap, onMount: error },
200
+ }))
201
+ }
181
202
  }
182
203
 
183
204
  update = (options?: FormOptions<TFormData, ValidatorType>) => {
@@ -245,6 +266,176 @@ export class FormApi<TFormData, ValidatorType> {
245
266
  return Promise.all(fieldValidationPromises)
246
267
  }
247
268
 
269
+ validateSync = (cause: ValidationCause): void => {
270
+ const { onChange, onBlur } = this.options
271
+ const validate =
272
+ cause === 'change' ? onChange : cause === 'blur' ? onBlur : undefined
273
+ if (!validate) return
274
+
275
+ const errorMapKey = getErrorMapKey(cause)
276
+ const doValidate = () => {
277
+ if (this.options.validator && typeof validate !== 'function') {
278
+ return (this.options.validator as Validator<TFormData>)().validate(
279
+ this.state.values,
280
+ validate,
281
+ )
282
+ }
283
+
284
+ return (validate as ValidateFn<TFormData, ValidatorType>)(
285
+ this.state.values,
286
+ this,
287
+ )
288
+ }
289
+
290
+ const error = normalizeError(doValidate())
291
+ if (this.state.errorMap[errorMapKey] !== error) {
292
+ this.store.setState((prev) => ({
293
+ ...prev,
294
+ errorMap: {
295
+ ...prev.errorMap,
296
+ [errorMapKey]: error,
297
+ },
298
+ }))
299
+ }
300
+
301
+ if (this.state.errorMap[errorMapKey]) {
302
+ this.cancelValidateAsync()
303
+ }
304
+ }
305
+
306
+ __leaseValidateAsync = () => {
307
+ const count = (this.validationMeta.validationAsyncCount || 0) + 1
308
+ this.validationMeta.validationAsyncCount = count
309
+ return count
310
+ }
311
+
312
+ cancelValidateAsync = () => {
313
+ // Lease a new validation count to ignore any pending validations
314
+ this.__leaseValidateAsync()
315
+ // Cancel any pending validation state
316
+ this.store.setState((prev) => ({
317
+ ...prev,
318
+ isFormValidating: false,
319
+ }))
320
+ }
321
+
322
+ validateAsync = async (
323
+ cause: ValidationCause,
324
+ ): Promise<ValidationError[]> => {
325
+ const {
326
+ onChangeAsync,
327
+ onBlurAsync,
328
+ asyncDebounceMs,
329
+ onBlurAsyncDebounceMs,
330
+ onChangeAsyncDebounceMs,
331
+ } = this.options
332
+
333
+ const validate =
334
+ cause === 'change'
335
+ ? onChangeAsync
336
+ : cause === 'blur'
337
+ ? onBlurAsync
338
+ : undefined
339
+
340
+ if (!validate) return []
341
+ const debounceMs =
342
+ (cause === 'change' ? onChangeAsyncDebounceMs : onBlurAsyncDebounceMs) ??
343
+ asyncDebounceMs ??
344
+ 0
345
+
346
+ if (!this.state.isFormValidating) {
347
+ this.store.setState((prev) => ({ ...prev, isFormValidating: true }))
348
+ }
349
+
350
+ // Use the validationCount for all field instances to
351
+ // track freshness of the validation
352
+ const validationAsyncCount = this.__leaseValidateAsync()
353
+
354
+ const checkLatest = () =>
355
+ validationAsyncCount === this.validationMeta.validationAsyncCount
356
+
357
+ if (!this.validationMeta.validationPromise) {
358
+ this.validationMeta.validationPromise = new Promise((resolve, reject) => {
359
+ this.validationMeta.validationResolve = resolve
360
+ this.validationMeta.validationReject = reject
361
+ })
362
+ }
363
+
364
+ if (debounceMs > 0) {
365
+ await new Promise((r) => setTimeout(r, debounceMs))
366
+ }
367
+
368
+ const doValidate = () => {
369
+ if (typeof validate === 'function') {
370
+ return validate(this.state.values, this) as ValidationError
371
+ }
372
+ if (this.options.validator && typeof validate !== 'function') {
373
+ return (this.options.validator as Validator<TFormData>)().validateAsync(
374
+ this.state.values,
375
+ validate,
376
+ )
377
+ }
378
+ const errorMapKey = getErrorMapKey(cause)
379
+ throw new Error(
380
+ `Form validation for ${errorMapKey}Async failed. ${errorMapKey}Async should either be a function, or \`validator\` should be correct.`,
381
+ )
382
+ }
383
+
384
+ // Only kick off validation if this validation is the latest attempt
385
+ if (checkLatest()) {
386
+ const prevErrors = this.state.errors
387
+ try {
388
+ const rawError = await doValidate()
389
+ if (checkLatest()) {
390
+ const error = normalizeError(rawError)
391
+ this.store.setState((prev) => ({
392
+ ...prev,
393
+ isFormValidating: false,
394
+ errorMap: {
395
+ ...prev.errorMap,
396
+ [getErrorMapKey(cause)]: error,
397
+ },
398
+ }))
399
+ this.validationMeta.validationResolve?.([...prevErrors, error])
400
+ }
401
+ } catch (error) {
402
+ if (checkLatest()) {
403
+ this.validationMeta.validationReject?.([...prevErrors, error])
404
+ throw error
405
+ }
406
+ } finally {
407
+ if (checkLatest()) {
408
+ this.store.setState((prev) => ({ ...prev, isFormValidating: false }))
409
+ delete this.validationMeta.validationPromise
410
+ }
411
+ }
412
+ }
413
+ // Always return the latest validation promise to the caller
414
+ return (await this.validationMeta.validationPromise) ?? []
415
+ }
416
+
417
+ validate = (
418
+ cause: ValidationCause,
419
+ ): ValidationError[] | Promise<ValidationError[]> => {
420
+ // Store the previous error for the errorMapKey (eg. onChange, onBlur, onSubmit)
421
+ const errorMapKey = getErrorMapKey(cause)
422
+ const prevError = this.state.errorMap[errorMapKey]
423
+
424
+ // Attempt to sync validate first
425
+ this.validateSync(cause)
426
+
427
+ const newError = this.state.errorMap[errorMapKey]
428
+ if (
429
+ prevError !== newError &&
430
+ !this.options.asyncAlways &&
431
+ !(newError === undefined && prevError !== undefined)
432
+ )
433
+ return this.state.errors
434
+
435
+ // No error? Attempt async validation
436
+ return this.validateAsync(cause)
437
+ }
438
+
248
439
  handleSubmit = async () => {
249
440
  // Check to see that the form and all fields have been touched
250
441
  // If they have not, touch them all and run validation
@@ -279,7 +470,7 @@ export class FormApi<TFormData, ValidatorType> {
279
470
  }
280
471
 
281
472
  // Run validation for the form
282
- // await this.validateForm()
473
+ await this.validate('submit')
283
474
 
284
475
  if (!this.state.isValid) {
285
476
  done()
@@ -428,3 +619,28 @@ export class FormApi<TFormData, ValidatorType> {
428
619
  })
429
620
  }
430
621
  }
622
+
623
+ function normalizeError(rawError?: ValidationError) {
624
+ if (rawError) {
625
+ if (typeof rawError !== 'string') {
626
+ return 'Invalid Form Values'
627
+ }
628
+
629
+ return rawError
630
+ }
631
+
632
+ return undefined
633
+ }
634
+
635
+ function getErrorMapKey(cause: ValidationCause) {
636
+ switch (cause) {
637
+ case 'submit':
638
+ return 'onSubmit'
639
+ case 'change':
640
+ return 'onChange'
641
+ case 'blur':
642
+ return 'onBlur'
643
+ case 'mount':
644
+ return 'onMount'
645
+ }
646
+ }
@@ -2,6 +2,7 @@ import { expect } from 'vitest'
2
2
 
3
3
  import { FormApi } from '../FormApi'
4
4
  import { FieldApi } from '../FieldApi'
5
+ import { sleep } from './utils'
5
6
 
6
7
  describe('form api', () => {
7
8
  it('should get default form state', () => {
@@ -16,6 +17,8 @@ describe('form api', () => {
16
17
  isFormValid: true,
17
18
  isFormValidating: false,
18
19
  isSubmitted: false,
20
+ errors: [],
21
+ errorMap: {},
19
22
  isSubmitting: false,
20
23
  isTouched: false,
21
24
  isValid: true,
@@ -39,6 +42,8 @@ describe('form api', () => {
39
42
  fieldMeta: {},
40
43
  canSubmit: true,
41
44
  isFieldsValid: true,
45
+ errors: [],
46
+ errorMap: {},
42
47
  isFieldsValidating: false,
43
48
  isFormValid: true,
44
49
  isFormValidating: false,
@@ -62,6 +67,8 @@ describe('form api', () => {
62
67
  expect(form.state).toEqual({
63
68
  values: {},
64
69
  fieldMeta: {},
70
+ errors: [],
71
+ errorMap: {},
65
72
  canSubmit: true,
66
73
  isFieldsValid: true,
67
74
  isFieldsValidating: false,
@@ -97,6 +104,8 @@ describe('form api', () => {
97
104
  values: {
98
105
  name: 'other',
99
106
  },
107
+ errors: [],
108
+ errorMap: {},
100
109
  fieldMeta: {},
101
110
  canSubmit: true,
102
111
  isFieldsValid: true,
@@ -129,6 +138,8 @@ describe('form api', () => {
129
138
  values: {
130
139
  name: 'test',
131
140
  },
141
+ errors: [],
142
+ errorMap: {},
132
143
  fieldMeta: {},
133
144
  canSubmit: true,
134
145
  isFieldsValid: true,
@@ -316,4 +327,345 @@ describe('form api', () => {
316
327
  expect(form.state.isFieldsValid).toEqual(true)
317
328
  expect(form.state.canSubmit).toEqual(true)
318
329
  })
330
+
331
+ it('should run validation onChange', () => {
332
+ const form = new FormApi({
333
+ defaultValues: {
334
+ name: 'test',
335
+ },
336
+ onChange: (value) => {
337
+ if (value.name === 'other') return 'Please enter a different value'
338
+ return
339
+ },
340
+ })
341
+
342
+ const field = new FieldApi({
343
+ form,
344
+ name: 'name',
345
+ })
346
+ form.mount()
347
+ field.mount()
348
+
349
+ expect(form.state.errors.length).toBe(0)
350
+ field.setValue('other', { touch: true })
351
+ expect(form.state.errors).toContain('Please enter a different value')
352
+ expect(form.state.errorMap).toMatchObject({
353
+ onChange: 'Please enter a different value',
354
+ })
355
+ })
356
+
357
+ it('should run async validation onChange', async () => {
358
+ vi.useFakeTimers()
359
+
360
+ const form = new FormApi({
361
+ defaultValues: {
362
+ name: 'test',
363
+ },
364
+ onChangeAsync: async (value) => {
365
+ await sleep(1000)
366
+ if (value.name === 'other') return 'Please enter a different value'
367
+ return
368
+ },
369
+ })
370
+ const field = new FieldApi({
371
+ form,
372
+ name: 'name',
373
+ })
374
+ form.mount()
375
+
376
+ field.mount()
377
+
378
+ expect(form.state.errors.length).toBe(0)
379
+ field.setValue('other', { touch: true })
380
+ await vi.runAllTimersAsync()
381
+ expect(form.state.errors).toContain('Please enter a different value')
382
+ expect(form.state.errorMap).toMatchObject({
383
+ onChange: 'Please enter a different value',
384
+ })
385
+ })
386
+
387
+ it('should run async validation onChange with debounce', async () => {
388
+ vi.useFakeTimers()
389
+ const sleepMock = vi.fn().mockImplementation(sleep)
390
+
391
+ const form = new FormApi({
392
+ defaultValues: {
393
+ name: 'test',
394
+ },
395
+ onChangeAsyncDebounceMs: 1000,
396
+ onChangeAsync: async (value) => {
397
+ await sleepMock(1000)
398
+ if (value.name === 'other') return 'Please enter a different value'
399
+ return
400
+ },
401
+ })
402
+ const field = new FieldApi({
403
+ form,
404
+ name: 'name',
405
+ })
406
+ form.mount()
407
+
408
+ field.mount()
409
+
410
+ expect(form.state.errors.length).toBe(0)
411
+ field.setValue('other', { touch: true })
412
+ field.setValue('other')
413
+ await vi.runAllTimersAsync()
414
+ // sleepMock will have been called 2 times without onChangeAsyncDebounceMs
415
+ expect(sleepMock).toHaveBeenCalledTimes(1)
416
+ expect(form.state.errors).toContain('Please enter a different value')
417
+ expect(form.state.errorMap).toMatchObject({
418
+ onChange: 'Please enter a different value',
419
+ })
420
+ })
421
+
422
+ it('should run async validation onChange with asyncDebounceMs', async () => {
423
+ vi.useFakeTimers()
424
+ const sleepMock = vi.fn().mockImplementation(sleep)
425
+
426
+ const form = new FormApi({
427
+ defaultValues: {
428
+ name: 'test',
429
+ },
430
+ asyncDebounceMs: 1000,
431
+ onChangeAsync: async (value) => {
432
+ await sleepMock(1000)
433
+ if (value.name === 'other') return 'Please enter a different value'
434
+ return
435
+ },
436
+ })
437
+ const field = new FieldApi({
438
+ form,
439
+ name: 'name',
440
+ })
441
+
442
+ form.mount()
443
+ field.mount()
444
+
445
+ expect(form.state.errors.length).toBe(0)
446
+ field.setValue('other', { touch: true })
447
+ field.setValue('other')
448
+ await vi.runAllTimersAsync()
449
+ // sleepMock will have been called 2 times without asyncDebounceMs
450
+ expect(sleepMock).toHaveBeenCalledTimes(1)
451
+ expect(form.state.errors).toContain('Please enter a different value')
452
+ expect(form.state.errorMap).toMatchObject({
453
+ onChange: 'Please enter a different value',
454
+ })
455
+ })
456
+
457
+ it('should run validation onBlur', () => {
458
+ const form = new FormApi({
459
+ defaultValues: {
460
+ name: 'other',
461
+ },
462
+ onBlur: (value) => {
463
+ if (value.name === 'other') return 'Please enter a different value'
464
+ return
465
+ },
466
+ })
467
+ const field = new FieldApi({
468
+ form,
469
+ name: 'name',
470
+ })
471
+
472
+ form.mount()
473
+ field.mount()
474
+
475
+ field.setValue('other', { touch: true })
476
+ field.validate('blur')
477
+ expect(form.state.errors).toContain('Please enter a different value')
478
+ expect(form.state.errorMap).toMatchObject({
479
+ onBlur: 'Please enter a different value',
480
+ })
481
+ })
482
+
483
+ it('should run async validation onBlur', async () => {
484
+ vi.useFakeTimers()
485
+
486
+ const form = new FormApi({
487
+ defaultValues: {
488
+ name: 'test',
489
+ },
490
+ onBlurAsync: async (value) => {
491
+ await sleep(1000)
492
+ if (value.name === 'other') return 'Please enter a different value'
493
+ return
494
+ },
495
+ })
496
+ const field = new FieldApi({
497
+ form,
498
+ name: 'name',
499
+ })
500
+
501
+ form.mount()
502
+ field.mount()
503
+
504
+ expect(form.state.errors.length).toBe(0)
505
+ field.setValue('other', { touch: true })
506
+ field.validate('blur')
507
+ await vi.runAllTimersAsync()
508
+ expect(form.state.errors).toContain('Please enter a different value')
509
+ expect(form.state.errorMap).toMatchObject({
510
+ onBlur: 'Please enter a different value',
511
+ })
512
+ })
513
+
514
+ it('should run async validation onBlur with debounce', async () => {
515
+ vi.useFakeTimers()
516
+ const sleepMock = vi.fn().mockImplementation(sleep)
517
+
518
+ const form = new FormApi({
519
+ defaultValues: {
520
+ name: 'test',
521
+ },
522
+ onBlurAsyncDebounceMs: 1000,
523
+ onBlurAsync: async (value) => {
524
+ await sleepMock(10)
525
+ if (value.name === 'other') return 'Please enter a different value'
526
+ return
527
+ },
528
+ })
529
+ const field = new FieldApi({
530
+ form,
531
+ name: 'name',
532
+ })
533
+
534
+ form.mount()
535
+ field.mount()
536
+
537
+ expect(form.state.errors.length).toBe(0)
538
+ field.setValue('other', { touch: true })
539
+ field.validate('blur')
540
+ field.validate('blur')
541
+ await vi.runAllTimersAsync()
542
+ // sleepMock will have been called 2 times without onBlurAsyncDebounceMs
543
+ expect(sleepMock).toHaveBeenCalledTimes(1)
544
+ expect(form.state.errors).toContain('Please enter a different value')
545
+ expect(form.state.errorMap).toMatchObject({
546
+ onBlur: 'Please enter a different value',
547
+ })
548
+ })
549
+
550
+ it('should run async validation onBlur with asyncDebounceMs', async () => {
551
+ vi.useFakeTimers()
552
+ const sleepMock = vi.fn().mockImplementation(sleep)
553
+
554
+ const form = new FormApi({
555
+ defaultValues: {
556
+ name: 'test',
557
+ },
558
+ asyncDebounceMs: 1000,
559
+ onBlurAsync: async (value) => {
560
+ await sleepMock(10)
561
+ if (value.name === 'other') return 'Please enter a different value'
562
+ return
563
+ },
564
+ })
565
+ const field = new FieldApi({
566
+ form,
567
+ name: 'name',
568
+ })
569
+
570
+ form.mount()
571
+ field.mount()
572
+
573
+ expect(form.state.errors.length).toBe(0)
574
+ field.setValue('other', { touch: true })
575
+ field.validate('blur')
576
+ field.validate('blur')
577
+ await vi.runAllTimersAsync()
578
+ // sleepMock will have been called 2 times without asyncDebounceMs
579
+ expect(sleepMock).toHaveBeenCalledTimes(1)
580
+ expect(form.state.errors).toContain('Please enter a different value')
581
+ expect(form.state.errorMap).toMatchObject({
582
+ onBlur: 'Please enter a different value',
583
+ })
584
+ })
585
+
586
+ it('should contain multiple errors when running validation onBlur and onChange', () => {
587
+ const form = new FormApi({
588
+ defaultValues: {
589
+ name: 'other',
590
+ },
591
+ onBlur: (value) => {
592
+ if (value.name === 'other') return 'Please enter a different value'
593
+ return
594
+ },
595
+ onChange: (value) => {
596
+ if (value.name === 'other') return 'Please enter a different value'
597
+ return
598
+ },
599
+ })
600
+ const field = new FieldApi({
601
+ form,
602
+ name: 'name',
603
+ })
604
+
605
+ form.mount()
606
+ field.mount()
607
+
608
+ field.setValue('other', { touch: true })
609
+ field.validate('blur')
610
+ expect(form.state.errors).toStrictEqual([
611
+ 'Please enter a different value',
612
+ 'Please enter a different value',
613
+ ])
614
+ expect(form.state.errorMap).toEqual({
615
+ onBlur: 'Please enter a different value',
616
+ onChange: 'Please enter a different value',
617
+ })
618
+ })
619
+
620
+ it('should reset onChange errors when the issue is resolved', () => {
621
+ const form = new FormApi({
622
+ defaultValues: {
623
+ name: 'other',
624
+ },
625
+ onChange: (value) => {
626
+ if (value.name === 'other') return 'Please enter a different value'
627
+ return
628
+ },
629
+ })
630
+ const field = new FieldApi({
631
+ form,
632
+ name: 'name',
633
+ })
634
+
635
+ form.mount()
636
+ field.mount()
637
+
638
+ field.setValue('other', { touch: true })
639
+ expect(form.state.errors).toStrictEqual(['Please enter a different value'])
640
+ expect(form.state.errorMap).toEqual({
641
+ onChange: 'Please enter a different value',
642
+ })
643
+ field.setValue('test', { touch: true })
644
+ expect(form.state.errors).toStrictEqual([])
645
+ expect(form.state.errorMap).toEqual({})
646
+ })
647
+
648
+ it('should return error onMount', () => {
649
+ const form = new FormApi({
650
+ defaultValues: {
651
+ name: 'other',
652
+ },
653
+ onMount: (value) => {
654
+ if (value.name === 'other') return 'Please enter a different value'
655
+ return
656
+ },
657
+ })
658
+ const field = new FieldApi({
659
+ form,
660
+ name: 'name',
661
+ })
662
+
663
+ form.mount()
664
+ field.mount()
665
+
666
+ expect(form.state.errors).toStrictEqual(['Please enter a different value'])
667
+ expect(form.state.errorMap).toEqual({
668
+ onMount: 'Please enter a different value',
669
+ })
670
+ })
319
671
  })