From 7834789ab1cfdb2df8a174ad51fa515357f5cce1 Mon Sep 17 00:00:00 2001 From: tomrehnstrom Date: Sat, 7 Jun 2025 11:53:27 +0200 Subject: [PATCH] feat: Async field onChange with submition handling --- examples/react/simple/src/index.tsx | 10 ++- packages/form-core/src/FieldApi.ts | 94 ++++++++++++++++++++++++++++- packages/form-core/src/FormApi.ts | 19 ++++++ 3 files changed, 119 insertions(+), 4 deletions(-) diff --git a/examples/react/simple/src/index.tsx b/examples/react/simple/src/index.tsx index 920f80588..60463e441 100644 --- a/examples/react/simple/src/index.tsx +++ b/examples/react/simple/src/index.tsx @@ -22,7 +22,7 @@ export default function App() { }, onSubmit: async ({ value }) => { // Do something with form data - console.log(value) + console.log(value, 'Submitted') }, }) @@ -76,6 +76,14 @@ export default function App() {
{ + await new Promise((resolve) => setTimeout(resolve, 1000)) + if (value === 'CHANGED') return + fieldApi.form.setFieldValue('lastName', 'CHANGED') + }, + }} children={(field) => ( <> diff --git a/packages/form-core/src/FieldApi.ts b/packages/form-core/src/FieldApi.ts index 33fcd8cc5..f8034b8a3 100644 --- a/packages/form-core/src/FieldApi.ts +++ b/packages/form-core/src/FieldApi.ts @@ -238,11 +238,11 @@ export type UnwrapFieldAsyncValidateOrFn< /** * @private */ -export type FieldListenerFn< +type FieldListenerFnProps< TParentData, TName extends DeepKeys, TData extends DeepValue = DeepValue, -> = (props: { +> = { value: TData fieldApi: FieldApi< TParentData, @@ -267,7 +267,25 @@ export type FieldListenerFn< any, any > -}) => void +} + +/** + * @private + */ +export type FieldListenerFn< + TParentData, + TName extends DeepKeys, + TData extends DeepValue = DeepValue, +> = (props: FieldListenerFnProps) => void + +/** + * @private + */ +export type FieldListenerAsyncFn< + TParentData, + TName extends DeepKeys, + TData extends DeepValue = DeepValue, +> = (props: FieldListenerFnProps) => Promise export interface FieldValidators< TParentData, @@ -357,6 +375,8 @@ export interface FieldListeners< > { onChange?: FieldListenerFn onChangeDebounceMs?: number + onChangeAsync?: FieldListenerAsyncFn + onChangeAsyncDebounceMs?: number onBlur?: FieldListenerFn onBlurDebounceMs?: number onMount?: FieldListenerFn @@ -983,12 +1003,17 @@ export class FieldApi< get state() { return this.store.state } + timeoutIds: { validations: Record | null> listeners: Record | null> formListeners: Record | null> } + promises: { + listeners: Record | null> + } + /** * Initializes a new `FieldApi` instance. */ @@ -1023,6 +1048,10 @@ export class FieldApi< formListeners: {} as Record, } + this.promises = { + listeners: {} as Record | null>, + } + this.store = new Derived({ deps: [this.form.store], fn: () => { @@ -1788,6 +1817,7 @@ export class FieldApi< }) } + this.triggerOnChangeAsyncListener() const fieldDebounceMs = this.options.listeners?.onChangeDebounceMs if (fieldDebounceMs && fieldDebounceMs > 0) { if (this.timeoutIds.listeners.change) { @@ -1807,6 +1837,64 @@ export class FieldApi< }) } } + + private abortController: AbortController = new AbortController() + private collapseController: AbortController = new AbortController() + private triggerOnChangeAsyncListener() { + const fieldDebounceMs = this.options.listeners?.onChangeAsyncDebounceMs + if (fieldDebounceMs && fieldDebounceMs > 0) { + if (this.timeoutIds.listeners.change) { + clearTimeout(this.timeoutIds.listeners.change) + this.abortController.abort() + } + + const debouncePromise = new Promise((resolve) => { + this.abortController.signal.onabort = () => { + resolve() + } + + this.collapseController.signal.onabort = () => { + this.options.listeners + ?.onChangeAsync?.({ + value: this.state.value, + fieldApi: this, + }) + .finally(resolve) + } + + this.timeoutIds.listeners.change = setTimeout(() => { + this.options.listeners + ?.onChangeAsync?.({ + value: this.state.value, + fieldApi: this, + }) + .finally(resolve) + }, fieldDebounceMs) + }).finally(() => { + this.promises.listeners.change = null + }) + this.promises.listeners.change = debouncePromise + } else { + const promise = this.options.listeners?.onChangeAsync?.({ + value: this.state.value, + fieldApi: this, + }) + + if (promise) { + promise.finally(() => { + this.promises.listeners.change = null + }) + this.promises.listeners.change = promise + } + } + } + + collapseFieldOnChangeAsync = () => { + if (this.timeoutIds.listeners.change) { + clearTimeout(this.timeoutIds.listeners.change) + } + this.collapseController.abort() + } } function normalizeError(rawError?: ValidationError) { diff --git a/packages/form-core/src/FormApi.ts b/packages/form-core/src/FormApi.ts index a048c21d2..8a3a7ada8 100644 --- a/packages/form-core/src/FormApi.ts +++ b/packages/form-core/src/FormApi.ts @@ -1306,6 +1306,24 @@ export class FormApi< return fieldErrorMapMap.flat() } + collapseAllFieldAsyncOnChange = async () => { + const proimises: Promise[] = [] + batch(() => { + void (Object.values(this.fieldInfo) as FieldInfo[]).forEach( + (field) => { + if (!field.instance) return + const promise = field.instance.promises.listeners.change + field.instance.collapseFieldOnChangeAsync() + if (promise) { + proimises.push(promise) + } + }, + ) + }) + + await Promise.all(proimises) + } + /** * Validates the children of a specified array in the form starting from a given index until the end using the correct handlers for a given validation type. */ @@ -1770,6 +1788,7 @@ export class FormApi< this.baseStore.setState((prev) => ({ ...prev, isSubmitting: false })) } + await this.collapseAllFieldAsyncOnChange() await this.validateAllFields('submit') if (!this.state.isFieldsValid) {