fix: router + store

This commit is contained in:
Tanner Linsley
2023-05-07 23:33:39 -06:00
parent ee4cc3f51f
commit 884235f211
7 changed files with 3574 additions and 448 deletions

View File

@@ -1,6 +1,11 @@
import React from "react";
import ReactDOM from "react-dom/client";
import { FieldApi, createFormFactory } from "@tanstack/react-form";
import {
FieldApi,
FormApi,
createFormFactory,
useField,
} from "@tanstack/react-form";
type Person = {
firstName: string;
@@ -35,7 +40,7 @@ function FieldInfo({ field }: { field: FieldApi<any, any> }) {
export default function App() {
const form = formFactory.useForm({
onSubmit: async (values) => {
onSubmit: async (values, formApi) => {
// Do something with form data
console.log(values);
},
@@ -54,11 +59,14 @@ export default function App() {
{/* A type-safe and pre-bound field component*/}
<form.Field
name="firstName"
validateOn="change"
validate={(value) => !value && "A first name is required"}
validateAsyncOn="change"
validateAsyncDebounceMs={500}
validateAsync={async (value) => {
onChange={(value) =>
!value
? "A first name is required"
: value.length < 3
? "First name must be at least 3 characters"
: undefined
}
onChangeAsync={async (value) => {
await new Promise((resolve) => setTimeout(resolve, 1000));
return (
value.includes("error") && 'No "error" allowed in first name'
@@ -68,7 +76,6 @@ export default function App() {
// Avoid hasty abstractions. Render props are great!
return (
<>
<input placeholder="uncontrolled" />
<input {...field.getInputProps()} />
<FieldInfo field={field} />
</>
@@ -76,7 +83,7 @@ export default function App() {
}}
/>
</div>
<div>
{/* <div>
<form.Field
name="lastName"
children={(field) => (
@@ -172,7 +179,7 @@ export default function App() {
</div>
)}
/>
</div>
</div> */}
<form.Subscribe
selector={(state) => [state.canSubmit, state.isSubmitting]}
children={([canSubmit, isSubmitting]) => (
@@ -188,4 +195,5 @@ export default function App() {
}
const rootElement = document.getElementById("root")!;
ReactDOM.createRoot(rootElement).render(<App />);

View File

@@ -116,6 +116,7 @@
]
},
"dependencies": {
"@tanstack/store": "^0.0.1-beta.84",
"fs-extra": "^11.1.1",
"rollup-plugin-dts": "^5.3.0"
}

View File

@@ -27,6 +27,6 @@
"build:types": "tsc --build"
},
"dependencies": {
"@tanstack/store": "0.0.1-beta.84"
"@tanstack/store": "0.0.1-beta.88"
}
}

View File

@@ -5,22 +5,30 @@ import { Store } from '@tanstack/store'
export type ValidationCause = 'change' | 'blur' | 'submit'
type ValidateFn<TData, TFormData> = (
value: TData,
fieldApi: FieldApi<TData, TFormData>,
) => ValidationError
type ValidateAsyncFn<TData, TFormData> = (
value: TData,
fieldApi: FieldApi<TData, TFormData>,
) => ValidationError | Promise<ValidationError>
export interface FieldOptions<TData, TFormData> {
name: unknown extends TFormData ? string : DeepKeys<TFormData>
index?: TData extends any[] ? number : never
defaultValue?: TData
validate?: (
value: TData,
fieldApi: FieldApi<TData, TFormData>,
) => ValidationError
validateAsync?: (
value: TData,
fieldApi: FieldApi<TData, TFormData>,
) => ValidationError | Promise<ValidationError>
validatePristine?: boolean // Default: false
validateOn?: ValidationCause // Default: 'change'
validateAsyncOn?: ValidationCause // Default: 'blur'
validateAsyncDebounceMs?: number
asyncDebounceMs?: number
asyncAlways?: boolean
onMount?: (formApi: FieldApi<TData, TFormData>) => void
onChange?: ValidateFn<TData, TFormData>
onChangeAsync?: ValidateAsyncFn<TData, TFormData>
onChangeAsyncDebounceMs?: number
onBlur?: ValidateFn<TData, TFormData>
onBlurAsync?: ValidateAsyncFn<TData, TFormData>
onBlurAsyncDebounceMs?: number
onSubmitAsync?: ValidateAsyncFn<TData, TFormData>
defaultMeta?: Partial<FieldMeta>
}
@@ -73,13 +81,7 @@ export class FieldApi<TData, TFormData> {
name!: DeepKeys<TFormData>
store!: Store<FieldState<TData>>
state!: FieldState<TData>
options: RequiredByKey<
FieldOptions<TData, TFormData>,
| 'validatePristine'
| 'validateOn'
| 'validateAsyncOn'
| 'validateAsyncDebounceMs'
> = {} as any
options: FieldOptions<TData, TFormData> = {} as any
constructor(opts: FieldApiOptions<TData, TFormData>) {
this.form = opts.form
@@ -108,12 +110,14 @@ export class FieldApi<TData, TFormData> {
? next.meta.error
: undefined
// Do not validate pristine fields
const prevState = this.state
const prev = this.state
this.state = next
if (next.value !== prevState.value) {
if (next.value !== prev.value) {
this.validate('change', next.value)
}
return this.state
},
},
)
@@ -126,10 +130,23 @@ export class FieldApi<TData, TFormData> {
const info = this.getInfo()
info.instances[this.uid] = this
const unsubscribe = this.form.store.subscribe(() => {
this.#updateStore()
const unsubscribe = this.form.store.subscribe((next) => {
this.store.batch(() => {
const nextValue = this.getValue()
const nextMeta = this.getMeta()
if (nextValue !== this.state.value) {
this.store.setState((prev) => ({ ...prev, value: nextValue }))
}
if (nextMeta !== this.state.meta) {
this.store.setState((prev) => ({ ...prev, meta: nextMeta }))
}
})
})
this.options.onMount?.(this)
return () => {
unsubscribe()
delete info.instances[this.uid]
@@ -139,28 +156,11 @@ export class FieldApi<TData, TFormData> {
}
}
#updateStore = () => {
this.store.batch(() => {
const nextValue = this.getValue()
const nextMeta = this.getMeta()
if (nextValue !== this.state.value) {
this.store.setState((prev) => ({ ...prev, value: nextValue }))
}
if (nextMeta !== this.state.meta) {
this.store.setState((prev) => ({ ...prev, meta: nextMeta }))
}
})
}
update = (opts: FieldApiOptions<TData, TFormData>) => {
this.options = {
validatePristine: this.form.options.defaultValidatePristine ?? false,
validateOn: this.form.options.defaultValidateOn ?? 'change',
validateAsyncOn: this.form.options.defaultValidateAsyncOn ?? 'blur',
validateAsyncDebounceMs:
this.form.options.defaultValidateAsyncDebounceMs ?? 0,
asyncDebounceMs: this.form.options.asyncDebounceMs ?? 0,
onChangeAsyncDebounceMs: this.form.options.onChangeAsyncDebounceMs ?? 0,
onBlurAsyncDebounceMs: this.form.options.onBlurAsyncDebounceMs ?? 0,
...opts,
}
@@ -207,12 +207,12 @@ export class FieldApi<TData, TFormData> {
form: this.form,
})
validateSync = async (value = this.state.value) => {
const { validate } = this.options
validateSync = async (value = this.state.value, cause: ValidationCause) => {
const { onChange, onBlur } = this.options
const validate =
cause === 'submit' ? undefined : cause === 'change' ? onChange : onBlur
if (!validate) {
return
}
if (!validate) return
// Use the validationCount for all field instances to
// track freshness of the validation
@@ -249,12 +249,33 @@ export class FieldApi<TData, TFormData> {
}))
}
validateAsync = async (value = this.state.value) => {
const { validateAsync, validateAsyncDebounceMs } = this.options
validateAsync = async (value = this.state.value, cause: ValidationCause) => {
const {
onChangeAsync,
onBlurAsync,
onSubmitAsync,
asyncDebounceMs,
onBlurAsyncDebounceMs,
onChangeAsyncDebounceMs,
} = this.options
if (!validateAsync) {
return
}
const validate =
cause === 'change'
? onChangeAsync
: cause === 'submit'
? onSubmitAsync
: onBlurAsync
if (!validate) return
const debounceMs =
cause === 'submit'
? 0
: (cause === 'change'
? onChangeAsyncDebounceMs
: onBlurAsyncDebounceMs) ??
asyncDebounceMs ??
500
if (this.state.meta.isValidating !== true)
this.setMeta((prev) => ({ ...prev, isValidating: true }))
@@ -273,14 +294,14 @@ export class FieldApi<TData, TFormData> {
})
}
if (validateAsyncDebounceMs > 0) {
await new Promise((r) => setTimeout(r, validateAsyncDebounceMs))
if (debounceMs > 0) {
await new Promise((r) => setTimeout(r, debounceMs))
}
// Only kick off validation if this validation is the latest attempt
if (checkLatest()) {
try {
const rawError = await validateAsync(value, this)
const rawError = await validate(value, this)
if (checkLatest()) {
const error = normalizeError(rawError)
@@ -308,44 +329,25 @@ export class FieldApi<TData, TFormData> {
return this.getInfo().validationPromise
}
shouldValidate = (isAsync: boolean, cause?: ValidationCause) => {
const { validateOn, validateAsyncOn } = this.options
const level = getValidationCauseLevel(cause)
// Must meet *at least* the validation level to validate,
// e.g. if validateOn is 'change' and validateCause is 'blur',
// the field will still validate
return Object.keys(validateCauseLevels).some((d) =>
isAsync
? validateAsyncOn
: validateOn === d && level >= validateCauseLevels[d],
)
}
validate = async (
cause?: ValidationCause,
validate = (
cause: ValidationCause,
value?: TData,
): Promise<ValidationError> => {
): ValidationError | Promise<ValidationError> => {
// If the field is pristine and validatePristine is false, do not validate
if (!this.options.validatePristine && !this.state.meta.isTouched) return
if (!this.state.meta.isTouched) return
// Attempt to sync validate first
if (this.shouldValidate(false, cause)) {
this.validateSync(value)
}
this.validateSync(value, cause)
// If there is an error, return it, do not attempt async validation
if (this.state.meta.error) {
return this.state.meta.error
if (!this.options.asyncAlways) {
return this.state.meta.error
}
}
// No error? Attempt async validation
if (this.shouldValidate(true, cause)) {
return this.validateAsync(value)
}
// If there is no sync error or async validation attempt, there is no error
return undefined
return this.validateAsync(value, cause)
}
getChangeProps = <T extends UserChangeProps<any>>(
@@ -359,9 +361,12 @@ export class FieldApi<TData, TFormData> {
props.onChange?.(value)
},
onBlur: (e) => {
const prevTouched = this.state.meta.isTouched
this.setMeta((prev) => ({ ...prev, isTouched: true }))
if (!prevTouched) {
this.validate('change')
}
this.validate('blur')
props.onBlur?.(e)
},
} as ChangeProps<TData> & Omit<T, keyof ChangeProps<TData>>
}
@@ -381,16 +386,6 @@ export class FieldApi<TData, TFormData> {
}
}
const validateCauseLevels = {
change: 0,
blur: 1,
submit: 2,
}
function getValidationCauseLevel(cause?: ValidationCause) {
return !cause ? 3 : validateCauseLevels[cause]
}
function normalizeError(rawError?: ValidationError) {
if (rawError) {
if (typeof rawError !== 'string') {

View File

@@ -17,13 +17,27 @@ export type FormSubmitEvent = Register extends {
export type FormOptions<TData> = {
defaultValues?: TData
defaultState?: Partial<FormState<TData>>
onSubmit?: (values: TData, formApi: FormApi<TData>) => void
onInvalidSubmit?: (values: TData, formApi: FormApi<TData>) => void
validate?: (values: TData, formApi: FormApi<TData>) => Promise<any>
defaultValidatePristine?: boolean
defaultValidateOn?: ValidationCause
defaultValidateAsyncOn?: ValidationCause
defaultValidateAsyncDebounceMs?: number
asyncDebounceMs?: number
onMount?: (values: TData, formApi: FormApi<TData>) => ValidationError
onMountAsync?: (
values: TData,
formApi: FormApi<TData>,
) => ValidationError | Promise<ValidationError>
onMountAsyncDebounceMs?: number
onChange?: (values: TData, formApi: FormApi<TData>) => ValidationError
onChangeAsync?: (
values: TData,
formApi: FormApi<TData>,
) => ValidationError | Promise<ValidationError>
onChangeAsyncDebounceMs?: number
onBlur?: (values: TData, formApi: FormApi<TData>) => ValidationError
onBlurAsync?: (
values: TData,
formApi: FormApi<TData>,
) => ValidationError | Promise<ValidationError>
onBlurAsyncDebounceMs?: number
onSubmit?: (values: TData, formApi: FormApi<TData>) => any | Promise<any>
onSubmitInvalid?: (values: TData, formApi: FormApi<TData>) => void
}
export type FieldInfo<TFormData> = {
@@ -99,7 +113,7 @@ export class FormApi<TFormData> {
getDefaultFormState({
...opts?.defaultState,
values: opts?.defaultValues ?? opts?.defaultState?.values,
isFormValid: !opts?.validate,
isFormValid: true,
}),
{
onUpdate: (next) => {
@@ -175,7 +189,7 @@ export class FormApi<TFormData> {
reset = () =>
this.store.setState(() => getDefaultFormState(this.options.defaultValues!))
validateAllFields = async () => {
validateAllFields = async (cause: ValidationCause) => {
const fieldValidationPromises: Promise<ValidationError>[] = [] as any
this.store.batch(() => {
@@ -187,9 +201,9 @@ export class FormApi<TFormData> {
// Mark them as touched
instance.setMeta((prev) => ({ ...prev, isTouched: true }))
// Validate the field
if (instance.options.validate) {
fieldValidationPromises.push(instance.validate())
}
fieldValidationPromises.push(
Promise.resolve().then(() => instance.validate(cause)),
)
}
})
},
@@ -199,63 +213,7 @@ export class FormApi<TFormData> {
return Promise.all(fieldValidationPromises)
}
validateForm = async () => {
const { validate } = this.options
if (!validate) {
return
}
// Use the formValidationCount for all field instances to
// track freshness of the validation
this.store.setState((prev) => ({
...prev,
isValidating: true,
formValidationCount: prev.formValidationCount + 1,
}))
const formValidationCount = this.state.formValidationCount
const checkLatest = () =>
formValidationCount === this.state.formValidationCount
if (!this.validationMeta.validationPromise) {
this.validationMeta.validationPromise = new Promise((resolve, reject) => {
this.validationMeta.validationResolve = resolve
this.validationMeta.validationReject = reject
})
}
const doValidation = async () => {
try {
const error = await validate(this.state.values, this)
if (checkLatest()) {
this.store.setState((prev) => ({
...prev,
isValidating: false,
error: error
? typeof error === 'string'
? error
: 'Invalid Form Values'
: null,
}))
this.validationMeta.validationResolve?.(error)
}
} catch (err) {
if (checkLatest()) {
this.validationMeta.validationReject?.(err)
}
} finally {
delete this.validationMeta.validationPromise
}
}
doValidation()
return this.validationMeta.validationPromise
}
validateForm = async () => {}
handleSubmit = async (e: FormSubmitEvent) => {
e.preventDefault()
@@ -284,12 +242,12 @@ export class FormApi<TFormData> {
}
// Validate all fields
await this.validateAllFields()
await this.validateAllFields('submit')
// Fields are invalid, do not submit
if (!this.state.isFieldsValid) {
done()
this.options.onInvalidSubmit?.(this.state.values, this)
this.options.onSubmitInvalid?.(this.state.values, this)
return
}
@@ -298,7 +256,7 @@ export class FormApi<TFormData> {
if (!this.state.isValid) {
done()
this.options.onInvalidSubmit?.(this.state.values, this)
this.options.onSubmitInvalid?.(this.state.values, this)
return
}
@@ -352,22 +310,22 @@ export class FormApi<TFormData> {
updater: Updater<DeepValue<TFormData, TField>>,
opts?: { touch?: boolean },
) => {
const touch = opts?.touch ?? true
const touch = opts?.touch
this.store.batch(() => {
this.store.setState((prev) => {
return {
...prev,
values: setBy(prev.values, field, updater),
}
})
if (touch) {
this.setFieldMeta(field, (prev) => ({
...prev,
isTouched: true,
}))
}
this.store.setState((prev) => {
return {
...prev,
values: setBy(prev.values, field, updater),
}
})
})
}
@@ -392,10 +350,6 @@ export class FormApi<TFormData> {
this.setFieldValue(
field,
(prev) => {
// invariant( // TODO: bring in invariant
// Array.isArray(prev),
// `Cannot insert a field value into a non-array field. Check that this field's existing value is an array: ${field}.`
// )
return (prev as DeepValue<TFormData, TField>[]).map((d, i) =>
i === index ? value : d,
) as any
@@ -412,10 +366,6 @@ export class FormApi<TFormData> {
this.setFieldValue(
field,
(prev) => {
// invariant( // TODO: bring in invariant
// Array.isArray(prev),
// `Cannot insert a field value into a non-array field. Check that this field's existing value is an array: ${field}.`
// )
return (prev as DeepValue<TFormData, TField>[]).filter(
(_d, i) => i !== index,
) as any

View File

@@ -37,7 +37,7 @@
},
"dependencies": {
"@tanstack/form-core": "workspace:*",
"@tanstack/react-store": "0.0.1-beta.84"
"@tanstack/react-store": "0.0.1-beta.85"
},
"peerDependencies": {
"react": "^17.0.0 || ^18.0.0",

3676
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff