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

View File

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

View File

@@ -27,6 +27,6 @@
"build:types": "tsc --build" "build:types": "tsc --build"
}, },
"dependencies": { "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' 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> { export interface FieldOptions<TData, TFormData> {
name: unknown extends TFormData ? string : DeepKeys<TFormData> name: unknown extends TFormData ? string : DeepKeys<TFormData>
index?: TData extends any[] ? number : never index?: TData extends any[] ? number : never
defaultValue?: TData defaultValue?: TData
validate?: ( asyncDebounceMs?: number
value: TData, asyncAlways?: boolean
fieldApi: FieldApi<TData, TFormData>, onMount?: (formApi: FieldApi<TData, TFormData>) => void
) => ValidationError onChange?: ValidateFn<TData, TFormData>
validateAsync?: ( onChangeAsync?: ValidateAsyncFn<TData, TFormData>
value: TData, onChangeAsyncDebounceMs?: number
fieldApi: FieldApi<TData, TFormData>, onBlur?: ValidateFn<TData, TFormData>
) => ValidationError | Promise<ValidationError> onBlurAsync?: ValidateAsyncFn<TData, TFormData>
validatePristine?: boolean // Default: false onBlurAsyncDebounceMs?: number
validateOn?: ValidationCause // Default: 'change' onSubmitAsync?: ValidateAsyncFn<TData, TFormData>
validateAsyncOn?: ValidationCause // Default: 'blur'
validateAsyncDebounceMs?: number
defaultMeta?: Partial<FieldMeta> defaultMeta?: Partial<FieldMeta>
} }
@@ -73,13 +81,7 @@ export class FieldApi<TData, TFormData> {
name!: DeepKeys<TFormData> name!: DeepKeys<TFormData>
store!: Store<FieldState<TData>> store!: Store<FieldState<TData>>
state!: FieldState<TData> state!: FieldState<TData>
options: RequiredByKey< options: FieldOptions<TData, TFormData> = {} as any
FieldOptions<TData, TFormData>,
| 'validatePristine'
| 'validateOn'
| 'validateAsyncOn'
| 'validateAsyncDebounceMs'
> = {} as any
constructor(opts: FieldApiOptions<TData, TFormData>) { constructor(opts: FieldApiOptions<TData, TFormData>) {
this.form = opts.form this.form = opts.form
@@ -108,12 +110,14 @@ export class FieldApi<TData, TFormData> {
? next.meta.error ? next.meta.error
: undefined : undefined
// Do not validate pristine fields const prev = this.state
const prevState = this.state
this.state = next this.state = next
if (next.value !== prevState.value) {
if (next.value !== prev.value) {
this.validate('change', next.value) this.validate('change', next.value)
} }
return this.state
}, },
}, },
) )
@@ -126,10 +130,23 @@ export class FieldApi<TData, TFormData> {
const info = this.getInfo() const info = this.getInfo()
info.instances[this.uid] = this info.instances[this.uid] = this
const unsubscribe = this.form.store.subscribe(() => { const unsubscribe = this.form.store.subscribe((next) => {
this.#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 }))
}
})
}) })
this.options.onMount?.(this)
return () => { return () => {
unsubscribe() unsubscribe()
delete info.instances[this.uid] 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>) => { update = (opts: FieldApiOptions<TData, TFormData>) => {
this.options = { this.options = {
validatePristine: this.form.options.defaultValidatePristine ?? false, asyncDebounceMs: this.form.options.asyncDebounceMs ?? 0,
validateOn: this.form.options.defaultValidateOn ?? 'change', onChangeAsyncDebounceMs: this.form.options.onChangeAsyncDebounceMs ?? 0,
validateAsyncOn: this.form.options.defaultValidateAsyncOn ?? 'blur', onBlurAsyncDebounceMs: this.form.options.onBlurAsyncDebounceMs ?? 0,
validateAsyncDebounceMs:
this.form.options.defaultValidateAsyncDebounceMs ?? 0,
...opts, ...opts,
} }
@@ -207,12 +207,12 @@ export class FieldApi<TData, TFormData> {
form: this.form, form: this.form,
}) })
validateSync = async (value = this.state.value) => { validateSync = async (value = this.state.value, cause: ValidationCause) => {
const { validate } = this.options const { onChange, onBlur } = this.options
const validate =
cause === 'submit' ? undefined : cause === 'change' ? onChange : onBlur
if (!validate) { if (!validate) return
return
}
// Use the validationCount for all field instances to // Use the validationCount for all field instances to
// track freshness of the validation // track freshness of the validation
@@ -249,12 +249,33 @@ export class FieldApi<TData, TFormData> {
})) }))
} }
validateAsync = async (value = this.state.value) => { validateAsync = async (value = this.state.value, cause: ValidationCause) => {
const { validateAsync, validateAsyncDebounceMs } = this.options const {
onChangeAsync,
onBlurAsync,
onSubmitAsync,
asyncDebounceMs,
onBlurAsyncDebounceMs,
onChangeAsyncDebounceMs,
} = this.options
if (!validateAsync) { const validate =
return 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) if (this.state.meta.isValidating !== true)
this.setMeta((prev) => ({ ...prev, isValidating: true })) this.setMeta((prev) => ({ ...prev, isValidating: true }))
@@ -273,14 +294,14 @@ export class FieldApi<TData, TFormData> {
}) })
} }
if (validateAsyncDebounceMs > 0) { if (debounceMs > 0) {
await new Promise((r) => setTimeout(r, validateAsyncDebounceMs)) await new Promise((r) => setTimeout(r, debounceMs))
} }
// Only kick off validation if this validation is the latest attempt // Only kick off validation if this validation is the latest attempt
if (checkLatest()) { if (checkLatest()) {
try { try {
const rawError = await validateAsync(value, this) const rawError = await validate(value, this)
if (checkLatest()) { if (checkLatest()) {
const error = normalizeError(rawError) const error = normalizeError(rawError)
@@ -308,44 +329,25 @@ export class FieldApi<TData, TFormData> {
return this.getInfo().validationPromise return this.getInfo().validationPromise
} }
shouldValidate = (isAsync: boolean, cause?: ValidationCause) => { validate = (
const { validateOn, validateAsyncOn } = this.options cause: ValidationCause,
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,
value?: TData, value?: TData,
): Promise<ValidationError> => { ): ValidationError | Promise<ValidationError> => {
// If the field is pristine and validatePristine is false, do not validate // 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 // Attempt to sync validate first
if (this.shouldValidate(false, cause)) { this.validateSync(value, cause)
this.validateSync(value)
}
// If there is an error, return it, do not attempt async validation // If there is an error, return it, do not attempt async validation
if (this.state.meta.error) { if (this.state.meta.error) {
return this.state.meta.error if (!this.options.asyncAlways) {
return this.state.meta.error
}
} }
// No error? Attempt async validation // No error? Attempt async validation
if (this.shouldValidate(true, cause)) { return this.validateAsync(value, cause)
return this.validateAsync(value)
}
// If there is no sync error or async validation attempt, there is no error
return undefined
} }
getChangeProps = <T extends UserChangeProps<any>>( getChangeProps = <T extends UserChangeProps<any>>(
@@ -359,9 +361,12 @@ export class FieldApi<TData, TFormData> {
props.onChange?.(value) props.onChange?.(value)
}, },
onBlur: (e) => { onBlur: (e) => {
const prevTouched = this.state.meta.isTouched
this.setMeta((prev) => ({ ...prev, isTouched: true })) this.setMeta((prev) => ({ ...prev, isTouched: true }))
if (!prevTouched) {
this.validate('change')
}
this.validate('blur') this.validate('blur')
props.onBlur?.(e)
}, },
} as ChangeProps<TData> & Omit<T, keyof ChangeProps<TData>> } 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) { function normalizeError(rawError?: ValidationError) {
if (rawError) { if (rawError) {
if (typeof rawError !== 'string') { if (typeof rawError !== 'string') {

View File

@@ -17,13 +17,27 @@ export type FormSubmitEvent = Register extends {
export type FormOptions<TData> = { export type FormOptions<TData> = {
defaultValues?: TData defaultValues?: TData
defaultState?: Partial<FormState<TData>> defaultState?: Partial<FormState<TData>>
onSubmit?: (values: TData, formApi: FormApi<TData>) => void asyncDebounceMs?: number
onInvalidSubmit?: (values: TData, formApi: FormApi<TData>) => void onMount?: (values: TData, formApi: FormApi<TData>) => ValidationError
validate?: (values: TData, formApi: FormApi<TData>) => Promise<any> onMountAsync?: (
defaultValidatePristine?: boolean values: TData,
defaultValidateOn?: ValidationCause formApi: FormApi<TData>,
defaultValidateAsyncOn?: ValidationCause ) => ValidationError | Promise<ValidationError>
defaultValidateAsyncDebounceMs?: number 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> = { export type FieldInfo<TFormData> = {
@@ -99,7 +113,7 @@ export class FormApi<TFormData> {
getDefaultFormState({ getDefaultFormState({
...opts?.defaultState, ...opts?.defaultState,
values: opts?.defaultValues ?? opts?.defaultState?.values, values: opts?.defaultValues ?? opts?.defaultState?.values,
isFormValid: !opts?.validate, isFormValid: true,
}), }),
{ {
onUpdate: (next) => { onUpdate: (next) => {
@@ -175,7 +189,7 @@ export class FormApi<TFormData> {
reset = () => reset = () =>
this.store.setState(() => getDefaultFormState(this.options.defaultValues!)) this.store.setState(() => getDefaultFormState(this.options.defaultValues!))
validateAllFields = async () => { validateAllFields = async (cause: ValidationCause) => {
const fieldValidationPromises: Promise<ValidationError>[] = [] as any const fieldValidationPromises: Promise<ValidationError>[] = [] as any
this.store.batch(() => { this.store.batch(() => {
@@ -187,9 +201,9 @@ export class FormApi<TFormData> {
// Mark them as touched // Mark them as touched
instance.setMeta((prev) => ({ ...prev, isTouched: true })) instance.setMeta((prev) => ({ ...prev, isTouched: true }))
// Validate the field // Validate the field
if (instance.options.validate) { fieldValidationPromises.push(
fieldValidationPromises.push(instance.validate()) Promise.resolve().then(() => instance.validate(cause)),
} )
} }
}) })
}, },
@@ -199,63 +213,7 @@ export class FormApi<TFormData> {
return Promise.all(fieldValidationPromises) return Promise.all(fieldValidationPromises)
} }
validateForm = async () => { 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
}
handleSubmit = async (e: FormSubmitEvent) => { handleSubmit = async (e: FormSubmitEvent) => {
e.preventDefault() e.preventDefault()
@@ -284,12 +242,12 @@ export class FormApi<TFormData> {
} }
// Validate all fields // Validate all fields
await this.validateAllFields() await this.validateAllFields('submit')
// Fields are invalid, do not submit // Fields are invalid, do not submit
if (!this.state.isFieldsValid) { if (!this.state.isFieldsValid) {
done() done()
this.options.onInvalidSubmit?.(this.state.values, this) this.options.onSubmitInvalid?.(this.state.values, this)
return return
} }
@@ -298,7 +256,7 @@ export class FormApi<TFormData> {
if (!this.state.isValid) { if (!this.state.isValid) {
done() done()
this.options.onInvalidSubmit?.(this.state.values, this) this.options.onSubmitInvalid?.(this.state.values, this)
return return
} }
@@ -352,22 +310,22 @@ export class FormApi<TFormData> {
updater: Updater<DeepValue<TFormData, TField>>, updater: Updater<DeepValue<TFormData, TField>>,
opts?: { touch?: boolean }, opts?: { touch?: boolean },
) => { ) => {
const touch = opts?.touch ?? true const touch = opts?.touch
this.store.batch(() => { this.store.batch(() => {
this.store.setState((prev) => {
return {
...prev,
values: setBy(prev.values, field, updater),
}
})
if (touch) { if (touch) {
this.setFieldMeta(field, (prev) => ({ this.setFieldMeta(field, (prev) => ({
...prev, ...prev,
isTouched: true, isTouched: true,
})) }))
} }
this.store.setState((prev) => {
return {
...prev,
values: setBy(prev.values, field, updater),
}
})
}) })
} }
@@ -392,10 +350,6 @@ export class FormApi<TFormData> {
this.setFieldValue( this.setFieldValue(
field, field,
(prev) => { (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) => return (prev as DeepValue<TFormData, TField>[]).map((d, i) =>
i === index ? value : d, i === index ? value : d,
) as any ) as any
@@ -412,10 +366,6 @@ export class FormApi<TFormData> {
this.setFieldValue( this.setFieldValue(
field, field,
(prev) => { (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( return (prev as DeepValue<TFormData, TField>[]).filter(
(_d, i) => i !== index, (_d, i) => i !== index,
) as any ) as any

View File

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

3676
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff