@tanstack/form-core 0.0.2 → 0.0.4

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/FieldApi.ts CHANGED
@@ -3,7 +3,7 @@ import type { DeepKeys, DeepValue, RequiredByKey, Updater } from './utils'
3
3
  import type { FormApi, ValidationError } from './FormApi'
4
4
  import { Store } from '@tanstack/store'
5
5
 
6
- type ValidateOn = 'change' | 'blur' | 'submit'
6
+ export type ValidationCause = 'change' | 'blur' | 'submit'
7
7
 
8
8
  export type FieldOptions<TData, TFormData> = {
9
9
  name: unknown extends TFormData ? string : DeepKeys<TFormData>
@@ -18,14 +18,10 @@ export type FieldOptions<TData, TFormData> = {
18
18
  fieldApi: FieldApi<TData, TFormData>,
19
19
  ) => ValidationError | Promise<ValidationError>
20
20
  validatePristine?: boolean // Default: false
21
- validateOn?: ValidateOn // Default: 'change'
22
- validateAsyncOn?: ValidateOn // Default: 'blur'
21
+ validateOn?: ValidationCause // Default: 'change'
22
+ validateAsyncOn?: ValidationCause // Default: 'blur'
23
23
  validateAsyncDebounceMs?: number
24
- filterValue?: (value: TData) => TData
25
24
  defaultMeta?: Partial<FieldMeta>
26
- change?: boolean
27
- blur?: boolean
28
- submit?: boolean
29
25
  }
30
26
 
31
27
  export type FieldMeta = {
@@ -65,7 +61,10 @@ export class FieldApi<TData, TFormData> {
65
61
  state!: FieldState<TData>
66
62
  options: RequiredByKey<
67
63
  FieldOptions<TData, TFormData>,
68
- 'validateOn' | 'validateAsyncOn'
64
+ | 'validatePristine'
65
+ | 'validateOn'
66
+ | 'validateAsyncOn'
67
+ | 'validateAsyncDebounceMs'
69
68
  > = {} as any
70
69
 
71
70
  constructor(opts: FieldApiOptions<TData, TFormData>) {
@@ -96,21 +95,11 @@ export class FieldApi<TData, TFormData> {
96
95
  : undefined
97
96
 
98
97
  // Do not validate pristine fields
99
- if (!this.options.validatePristine && !next.meta.isTouched) return
100
-
101
- // If validateOn is set to a variation of change, run the validation
102
- if (
103
- this.options.validateOn === 'change' ||
104
- this.options.validateOn.split('-')[0] === 'change'
105
- ) {
106
- try {
107
- this.validate()
108
- } catch (err) {
109
- console.error('An error occurred during validation', err)
110
- }
111
- }
112
-
98
+ const prevState = this.state
113
99
  this.state = next
100
+ if (next.value !== prevState.value) {
101
+ this.validate('change', next.value)
102
+ }
114
103
  },
115
104
  },
116
105
  )
@@ -153,9 +142,11 @@ export class FieldApi<TData, TFormData> {
153
142
 
154
143
  update = (opts: FieldApiOptions<TData, TFormData>) => {
155
144
  this.options = {
156
- validateOn: 'change',
157
- validateAsyncOn: 'blur',
158
- validateAsyncDebounceMs: 0,
145
+ validatePristine: this.form.options.defaultValidatePristine ?? false,
146
+ validateOn: this.form.options.defaultValidateOn ?? 'change',
147
+ validateAsyncOn: this.form.options.defaultValidateAsyncOn ?? 'blur',
148
+ validateAsyncDebounceMs:
149
+ this.form.options.defaultValidateAsyncDebounceMs ?? 0,
159
150
  ...opts,
160
151
  }
161
152
 
@@ -200,20 +191,64 @@ export class FieldApi<TData, TFormData> {
200
191
  form: this.form,
201
192
  })
202
193
 
203
- #validate = async (isAsync: boolean) => {
204
- if (!this.options.validate) {
194
+ validateSync = async (value = this.state.value) => {
195
+ const { validate } = this.options
196
+
197
+ if (!validate) {
205
198
  return
206
199
  }
207
200
 
208
- this.setMeta((prev) => ({ ...prev, isValidating: true }))
209
-
210
201
  // Use the validationCount for all field instances to
211
202
  // track freshness of the validation
212
203
  const validationCount = (this.getInfo().validationCount || 0) + 1
213
-
214
204
  this.getInfo().validationCount = validationCount
205
+ const error = normalizeError(validate(value, this))
206
+
207
+ if (this.state.meta.error !== error) {
208
+ this.setMeta((prev) => ({
209
+ ...prev,
210
+ error,
211
+ }))
212
+ }
213
+
214
+ // If a sync error is encountered, cancel any async validation
215
+ if (this.state.meta.error) {
216
+ this.cancelValidateAsync()
217
+ }
218
+ }
219
+
220
+ #leaseValidateAsync = () => {
221
+ const count = (this.getInfo().validationAsyncCount || 0) + 1
222
+ this.getInfo().validationAsyncCount = count
223
+ return count
224
+ }
225
+
226
+ cancelValidateAsync = () => {
227
+ // Lease a new validation count to ignore any pending validations
228
+ this.#leaseValidateAsync()
229
+ // Cancel any pending validation state
230
+ this.setMeta((prev) => ({
231
+ ...prev,
232
+ isValidating: false,
233
+ }))
234
+ }
215
235
 
216
- const checkLatest = () => validationCount === this.getInfo().validationCount
236
+ validateAsync = async (value = this.state.value) => {
237
+ const { validateAsync, validateAsyncDebounceMs } = this.options
238
+
239
+ if (!validateAsync) {
240
+ return
241
+ }
242
+
243
+ if (this.state.meta.isValidating !== true)
244
+ this.setMeta((prev) => ({ ...prev, isValidating: true }))
245
+
246
+ // Use the validationCount for all field instances to
247
+ // track freshness of the validation
248
+ const validationAsyncCount = this.#leaseValidateAsync()
249
+
250
+ const checkLatest = () =>
251
+ validationAsyncCount === this.getInfo().validationAsyncCount
217
252
 
218
253
  if (!this.getInfo().validationPromise) {
219
254
  this.getInfo().validationPromise = new Promise((resolve, reject) => {
@@ -222,46 +257,80 @@ export class FieldApi<TData, TFormData> {
222
257
  })
223
258
  }
224
259
 
225
- try {
226
- const rawError = await this.options.validate(this.state.value, this)
227
-
228
- if (checkLatest()) {
229
- const error = (() => {
230
- if (rawError) {
231
- if (typeof rawError !== 'string') {
232
- return 'Invalid Form Values'
233
- }
234
-
235
- return rawError
236
- }
237
-
238
- return undefined
239
- })()
260
+ if (validateAsyncDebounceMs > 0) {
261
+ await new Promise((r) => setTimeout(r, validateAsyncDebounceMs))
262
+ }
240
263
 
241
- this.setMeta((prev) => ({
242
- ...prev,
243
- isValidating: false,
244
- error,
245
- }))
246
- this.getInfo().validationResolve?.(error)
247
- }
248
- } catch (error) {
249
- if (checkLatest()) {
250
- this.getInfo().validationReject?.(error)
251
- throw error
252
- }
253
- } finally {
254
- if (checkLatest()) {
255
- this.setMeta((prev) => ({ ...prev, isValidating: false }))
256
- delete this.getInfo().validationPromise
264
+ // Only kick off validation if this validation is the latest attempt
265
+ if (checkLatest()) {
266
+ try {
267
+ const rawError = await validateAsync(value, this)
268
+
269
+ if (checkLatest()) {
270
+ const error = normalizeError(rawError)
271
+ this.setMeta((prev) => ({
272
+ ...prev,
273
+ isValidating: false,
274
+ error,
275
+ }))
276
+ this.getInfo().validationResolve?.(error)
277
+ }
278
+ } catch (error) {
279
+ if (checkLatest()) {
280
+ this.getInfo().validationReject?.(error)
281
+ throw error
282
+ }
283
+ } finally {
284
+ if (checkLatest()) {
285
+ this.setMeta((prev) => ({ ...prev, isValidating: false }))
286
+ delete this.getInfo().validationPromise
287
+ }
257
288
  }
258
289
  }
259
290
 
291
+ // Always return the latest validation promise to the caller
260
292
  return this.getInfo().validationPromise
261
293
  }
262
294
 
263
- validate = () => this.#validate(false)
264
- validateAsync = () => this.#validate(true)
295
+ shouldValidate = (isAsync: boolean, cause?: ValidationCause) => {
296
+ const { validateOn, validateAsyncOn } = this.options
297
+ const level = getValidationCauseLevel(cause)
298
+
299
+ // Must meet *at least* the validation level to validate,
300
+ // e.g. if validateOn is 'change' and validateCause is 'blur',
301
+ // the field will still validate
302
+ return Object.keys(validateCauseLevels).some((d) =>
303
+ isAsync
304
+ ? validateAsyncOn
305
+ : validateOn === d && level >= validateCauseLevels[d],
306
+ )
307
+ }
308
+
309
+ validate = async (
310
+ cause?: ValidationCause,
311
+ value?: TData,
312
+ ): Promise<ValidationError> => {
313
+ // If the field is pristine and validatePristine is false, do not validate
314
+ if (!this.options.validatePristine && !this.state.meta.isTouched) return
315
+
316
+ // Attempt to sync validate first
317
+ if (this.shouldValidate(false, cause)) {
318
+ this.validateSync(value)
319
+ }
320
+
321
+ // If there is an error, return it, do not attempt async validation
322
+ if (this.state.meta.error) {
323
+ return this.state.meta.error
324
+ }
325
+
326
+ // No error? Attempt async validation
327
+ if (this.shouldValidate(true, cause)) {
328
+ return this.validateAsync(value)
329
+ }
330
+
331
+ // If there is no sync error or async validation attempt, there is no error
332
+ return undefined
333
+ }
265
334
 
266
335
  getChangeProps = <T extends ChangeProps<any>>(
267
336
  props: T = {} as T,
@@ -275,13 +344,7 @@ export class FieldApi<TData, TFormData> {
275
344
  },
276
345
  onBlur: (e) => {
277
346
  this.setMeta((prev) => ({ ...prev, isTouched: true }))
278
-
279
- const { validateOn } = this.options
280
-
281
- if (validateOn === 'blur' || validateOn.split('-')[0] === 'blur') {
282
- this.validate()
283
- }
284
-
347
+ this.validate('blur')
285
348
  props.onBlur?.(e)
286
349
  },
287
350
  } as ChangeProps<TData> & Omit<T, keyof ChangeProps<TData>>
@@ -301,3 +364,25 @@ export class FieldApi<TData, TFormData> {
301
364
  }
302
365
  }
303
366
  }
367
+
368
+ const validateCauseLevels = {
369
+ change: 0,
370
+ blur: 1,
371
+ submit: 2,
372
+ }
373
+
374
+ function getValidationCauseLevel(cause?: ValidationCause) {
375
+ return !cause ? 3 : validateCauseLevels[cause]
376
+ }
377
+
378
+ function normalizeError(rawError?: ValidationError) {
379
+ if (rawError) {
380
+ if (typeof rawError !== 'string') {
381
+ return 'Invalid Form Values'
382
+ }
383
+
384
+ return rawError
385
+ }
386
+
387
+ return undefined
388
+ }
package/src/FormApi.ts CHANGED
@@ -3,16 +3,19 @@ import { Store } from '@tanstack/store'
3
3
  //
4
4
  import type { DeepKeys, DeepValue, Updater } from './utils'
5
5
  import { functionalUpdate, getBy, setBy } from './utils'
6
- import type { FieldApi, FieldMeta } from './FieldApi'
6
+ import type { FieldApi, FieldMeta, ValidationCause } from './FieldApi'
7
7
 
8
8
  export type FormOptions<TData> = {
9
9
  defaultValues?: TData
10
10
  defaultState?: Partial<FormState<TData>>
11
- onSubmit?: (values: TData, formApi: FormApi<TData>) => Promise<any>
11
+ onSubmit?: (values: TData, formApi: FormApi<TData>) => void
12
12
  onInvalidSubmit?: (values: TData, formApi: FormApi<TData>) => void
13
13
  validate?: (values: TData, formApi: FormApi<TData>) => Promise<any>
14
14
  debugForm?: boolean
15
- validatePristine?: boolean
15
+ defaultValidatePristine?: boolean
16
+ defaultValidateOn?: ValidationCause
17
+ defaultValidateAsyncOn?: ValidationCause
18
+ defaultValidateAsyncDebounceMs?: number
16
19
  }
17
20
 
18
21
  export type FieldInfo<TFormData> = {
@@ -21,6 +24,7 @@ export type FieldInfo<TFormData> = {
21
24
 
22
25
  export type ValidationMeta = {
23
26
  validationCount?: number
27
+ validationAsyncCount?: number
24
28
  validationPromise?: Promise<ValidationError>
25
29
  validationResolve?: (error: ValidationError) => void
26
30
  validationReject?: (error: unknown) => void