@tanstack/form-core 0.7.1 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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,14 +176,23 @@ 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)
179
+ const doValidate = () => {
180
+ if (typeof this.options.onMount === 'function') {
181
+ return this.options.onMount(this.state.values, this)
182
+ }
183
+ if (this.options.validator) {
184
+ return (this.options.validator as Validator<TFormData>)().validate(
185
+ this.state.values,
186
+ this.options.onMount,
187
+ )
188
+ }
174
189
  }
175
- if (this.options.validator) {
176
- return (this.options.validator as Validator<TFormData>)().validate(
177
- this.state.values,
178
- this.options.onMount,
179
- )
190
+ const error = doValidate()
191
+ if (error) {
192
+ this.store.setState((prev) => ({
193
+ ...prev,
194
+ errorMap: { ...prev.errorMap, onMount: error },
195
+ }))
180
196
  }
181
197
  }
182
198
 
@@ -245,6 +261,177 @@ export class FormApi<TFormData, ValidatorType> {
245
261
  return Promise.all(fieldValidationPromises)
246
262
  }
247
263
 
264
+ validateSync = (cause: ValidationCause): void => {
265
+ const { onChange, onBlur } = this.options
266
+ const validate =
267
+ cause === 'change' ? onChange : cause === 'blur' ? onBlur : undefined
268
+ if (!validate) return
269
+
270
+ const errorMapKey = getErrorMapKey(cause)
271
+ const doValidate = () => {
272
+ if (typeof validate === 'function') {
273
+ return validate(this.state.values, this) as ValidationError
274
+ }
275
+ if (this.options.validator && typeof validate !== 'function') {
276
+ return (this.options.validator as Validator<TFormData>)().validate(
277
+ this.state.values,
278
+ validate,
279
+ )
280
+ }
281
+ throw new Error(
282
+ `Form validation for ${errorMapKey} failed. ${errorMapKey} should either be a function, or \`validator\` should be correct.`,
283
+ )
284
+ }
285
+
286
+ const error = normalizeError(doValidate())
287
+ if (this.state.errorMap[errorMapKey] !== error) {
288
+ this.store.setState((prev) => ({
289
+ ...prev,
290
+ errorMap: {
291
+ ...prev.errorMap,
292
+ [errorMapKey]: error,
293
+ },
294
+ }))
295
+ }
296
+
297
+ if (this.state.errorMap[errorMapKey]) {
298
+ this.cancelValidateAsync()
299
+ }
300
+ }
301
+
302
+ __leaseValidateAsync = () => {
303
+ const count = (this.validationMeta.validationAsyncCount || 0) + 1
304
+ this.validationMeta.validationAsyncCount = count
305
+ return count
306
+ }
307
+
308
+ cancelValidateAsync = () => {
309
+ // Lease a new validation count to ignore any pending validations
310
+ this.__leaseValidateAsync()
311
+ // Cancel any pending validation state
312
+ this.store.setState((prev) => ({
313
+ ...prev,
314
+ isFormValidating: false,
315
+ }))
316
+ }
317
+
318
+ validateAsync = async (
319
+ cause: ValidationCause,
320
+ ): Promise<ValidationError[]> => {
321
+ const {
322
+ onChangeAsync,
323
+ onBlurAsync,
324
+ asyncDebounceMs,
325
+ onBlurAsyncDebounceMs,
326
+ onChangeAsyncDebounceMs,
327
+ } = this.options
328
+
329
+ const validate =
330
+ cause === 'change'
331
+ ? onChangeAsync
332
+ : cause === 'blur'
333
+ ? onBlurAsync
334
+ : undefined
335
+
336
+ if (!validate) return []
337
+ const debounceMs =
338
+ (cause === 'change' ? onChangeAsyncDebounceMs : onBlurAsyncDebounceMs) ??
339
+ asyncDebounceMs ??
340
+ 0
341
+
342
+ if (!this.state.isFormValidating) {
343
+ this.store.setState((prev) => ({ ...prev, isFormValidating: true }))
344
+ }
345
+
346
+ // Use the validationCount for all field instances to
347
+ // track freshness of the validation
348
+ const validationAsyncCount = this.__leaseValidateAsync()
349
+
350
+ const checkLatest = () =>
351
+ validationAsyncCount === this.validationMeta.validationAsyncCount
352
+
353
+ if (!this.validationMeta.validationPromise) {
354
+ this.validationMeta.validationPromise = new Promise((resolve, reject) => {
355
+ this.validationMeta.validationResolve = resolve
356
+ this.validationMeta.validationReject = reject
357
+ })
358
+ }
359
+
360
+ if (debounceMs > 0) {
361
+ await new Promise((r) => setTimeout(r, debounceMs))
362
+ }
363
+
364
+ const doValidate = () => {
365
+ if (typeof validate === 'function') {
366
+ return validate(this.state.values, this) as ValidationError
367
+ }
368
+ if (this.options.validator && typeof validate !== 'function') {
369
+ return (this.options.validator as Validator<TFormData>)().validateAsync(
370
+ this.state.values,
371
+ validate,
372
+ )
373
+ }
374
+ const errorMapKey = getErrorMapKey(cause)
375
+ throw new Error(
376
+ `Form validation for ${errorMapKey}Async failed. ${errorMapKey}Async should either be a function, or \`validator\` should be correct.`,
377
+ )
378
+ }
379
+
380
+ // Only kick off validation if this validation is the latest attempt
381
+ if (checkLatest()) {
382
+ const prevErrors = this.state.errors
383
+ try {
384
+ const rawError = await doValidate()
385
+ if (checkLatest()) {
386
+ const error = normalizeError(rawError)
387
+ this.store.setState((prev) => ({
388
+ ...prev,
389
+ isFormValidating: false,
390
+ errorMap: {
391
+ ...prev.errorMap,
392
+ [getErrorMapKey(cause)]: error,
393
+ },
394
+ }))
395
+ this.validationMeta.validationResolve?.([...prevErrors, error])
396
+ }
397
+ } catch (error) {
398
+ if (checkLatest()) {
399
+ this.validationMeta.validationReject?.([...prevErrors, error])
400
+ throw error
401
+ }
402
+ } finally {
403
+ if (checkLatest()) {
404
+ this.store.setState((prev) => ({ ...prev, isFormValidating: false }))
405
+ delete this.validationMeta.validationPromise
406
+ }
407
+ }
408
+ }
409
+ // Always return the latest validation promise to the caller
410
+ return (await this.validationMeta.validationPromise) ?? []
411
+ }
412
+
413
+ validate = (
414
+ cause: ValidationCause,
415
+ ): ValidationError[] | Promise<ValidationError[]> => {
416
+ // Store the previous error for the errorMapKey (eg. onChange, onBlur, onSubmit)
417
+ const errorMapKey = getErrorMapKey(cause)
418
+ const prevError = this.state.errorMap[errorMapKey]
419
+
420
+ // Attempt to sync validate first
421
+ this.validateSync(cause)
422
+
423
+ const newError = this.state.errorMap[errorMapKey]
424
+ if (
425
+ prevError !== newError &&
426
+ !this.options.asyncAlways &&
427
+ !(newError === undefined && prevError !== undefined)
428
+ )
429
+ return this.state.errors
430
+
431
+ // No error? Attempt async validation
432
+ return this.validateAsync(cause)
433
+ }
434
+
248
435
  handleSubmit = async () => {
249
436
  // Check to see that the form and all fields have been touched
250
437
  // If they have not, touch them all and run validation
@@ -279,7 +466,7 @@ export class FormApi<TFormData, ValidatorType> {
279
466
  }
280
467
 
281
468
  // Run validation for the form
282
- // await this.validateForm()
469
+ await this.validate('submit')
283
470
 
284
471
  if (!this.state.isValid) {
285
472
  done()
@@ -360,8 +547,11 @@ export class FormApi<TFormData, ValidatorType> {
360
547
  }
361
548
 
362
549
  deleteField = <TField extends DeepKeys<TFormData>>(field: TField) => {
363
- delete this.state.values[field as keyof TFormData]
364
- delete this.state.fieldMeta[field]
550
+ const newState = { ...this.state }
551
+ delete newState.values[field as keyof TFormData]
552
+ delete newState.fieldMeta[field]
553
+
554
+ this.store.setState((_) => newState)
365
555
  }
366
556
 
367
557
  pushFieldValue = <TField extends DeepKeys<TFormData>>(
@@ -425,3 +615,28 @@ export class FormApi<TFormData, ValidatorType> {
425
615
  })
426
616
  }
427
617
  }
618
+
619
+ function normalizeError(rawError?: ValidationError) {
620
+ if (rawError) {
621
+ if (typeof rawError !== 'string') {
622
+ return 'Invalid Form Values'
623
+ }
624
+
625
+ return rawError
626
+ }
627
+
628
+ return undefined
629
+ }
630
+
631
+ function getErrorMapKey(cause: ValidationCause) {
632
+ switch (cause) {
633
+ case 'submit':
634
+ return 'onSubmit'
635
+ case 'change':
636
+ return 'onChange'
637
+ case 'blur':
638
+ return 'onBlur'
639
+ case 'mount':
640
+ return 'onMount'
641
+ }
642
+ }
@@ -1,4 +1,4 @@
1
- import { expect } from 'vitest'
1
+ import { expect, vitest } from 'vitest'
2
2
 
3
3
  import { FormApi } from '../FormApi'
4
4
  import { FieldApi } from '../FieldApi'
@@ -597,11 +597,17 @@ describe('field api', () => {
597
597
  })
598
598
 
599
599
  const unmount = field.mount()
600
+ const callback = vitest.fn()
601
+ const subscription = form.store.subscribe(callback)
600
602
  unmount()
601
603
  const info = form.getFieldInfo(field.name)
604
+ subscription()
602
605
  expect(info.instances[field.uid]).toBeUndefined()
603
606
  expect(Object.keys(info.instances).length).toBe(0)
604
607
 
608
+ // Check that form store has been updated
609
+ expect(callback).toHaveBeenCalledOnce()
610
+
605
611
  // Field should have been removed from the form as well
606
612
  expect(form.state.values.name).toBeUndefined()
607
613
  expect(form.state.fieldMeta.name).toBeUndefined()