diff --git a/docs/framework/react/guides/listeners.md b/docs/framework/react/guides/listeners.md index 1aff32763..d57cd8c87 100644 --- a/docs/framework/react/guides/listeners.md +++ b/docs/framework/react/guides/listeners.md @@ -92,7 +92,7 @@ We enable an easy method for debouncing your listeners by adding a `onChangeDebo ### Form listeners -At a higher level, listeners are also available at the form level, allowing you access to the `onMount` and `onSubmit` events, and having `onChange` and `onBlur` propagated to all the form's children. +At a higher level, listeners are also available at the form level, allowing you access to the `onMount` and `onSubmit` events, and having `onChange` and `onBlur` propagated to all the form's children. Form-level listeners can also be debounced in the same way as previously discussed. `onMount` and `onSubmit` listeners have to following props: @@ -120,6 +120,7 @@ const form = useForm({ // fieldApi represents the field that triggered the event. console.log(fieldApi.name, fieldApi.state.value) }, + onChangeDebounceMs: 500, }, }) ``` diff --git a/packages/form-core/src/FieldApi.ts b/packages/form-core/src/FieldApi.ts index 70bcc80a1..8ecd4a632 100644 --- a/packages/form-core/src/FieldApi.ts +++ b/packages/form-core/src/FieldApi.ts @@ -978,6 +978,7 @@ export class FieldApi< timeoutIds: { validations: Record | null> listeners: Record | null> + formListeners: Record | null> } /** @@ -1011,6 +1012,7 @@ export class FieldApi< this.timeoutIds = { validations: {} as Record, listeners: {} as Record, + formListeners: {} as Record, } this.store = new Derived({ @@ -1703,13 +1705,27 @@ export class FieldApi< } private triggerOnBlurListener() { - const debounceMs = this.options.listeners?.onBlurDebounceMs - this.form.options.listeners?.onBlur?.({ - formApi: this.form, - fieldApi: this, - }) + const formDebounceMs = this.form.options.listeners?.onBlurDebounceMs + if (formDebounceMs && formDebounceMs > 0) { + if (this.timeoutIds.formListeners.blur) { + clearTimeout(this.timeoutIds.formListeners.blur) + } + + this.timeoutIds.formListeners.blur = setTimeout(() => { + this.form.options.listeners?.onBlur?.({ + formApi: this.form, + fieldApi: this, + }) + }, formDebounceMs) + } else { + this.form.options.listeners?.onBlur?.({ + formApi: this.form, + fieldApi: this, + }) + } - if (debounceMs && debounceMs > 0) { + const fieldDebounceMs = this.options.listeners?.onBlurDebounceMs + if (fieldDebounceMs && fieldDebounceMs > 0) { if (this.timeoutIds.listeners.blur) { clearTimeout(this.timeoutIds.listeners.blur) } @@ -1719,7 +1735,7 @@ export class FieldApi< value: this.state.value, fieldApi: this, }) - }, debounceMs) + }, fieldDebounceMs) } else { this.options.listeners?.onBlur?.({ value: this.state.value, @@ -1729,14 +1745,27 @@ export class FieldApi< } private triggerOnChangeListener() { - const debounceMs = this.options.listeners?.onChangeDebounceMs + const formDebounceMs = this.form.options.listeners?.onChangeDebounceMs + if (formDebounceMs && formDebounceMs > 0) { + if (this.timeoutIds.formListeners.blur) { + clearTimeout(this.timeoutIds.formListeners.blur) + } - this.form.options.listeners?.onChange?.({ - formApi: this.form, - fieldApi: this, - }) + this.timeoutIds.formListeners.blur = setTimeout(() => { + this.form.options.listeners?.onChange?.({ + formApi: this.form, + fieldApi: this, + }) + }, formDebounceMs) + } else { + this.form.options.listeners?.onChange?.({ + formApi: this.form, + fieldApi: this, + }) + } - if (debounceMs && debounceMs > 0) { + const fieldDebounceMs = this.options.listeners?.onChangeDebounceMs + if (fieldDebounceMs && fieldDebounceMs > 0) { if (this.timeoutIds.listeners.change) { clearTimeout(this.timeoutIds.listeners.change) } @@ -1746,7 +1775,7 @@ export class FieldApi< value: this.state.value, fieldApi: this, }) - }, debounceMs) + }, fieldDebounceMs) } else { this.options.listeners?.onChange?.({ value: this.state.value, diff --git a/packages/form-core/src/FormApi.ts b/packages/form-core/src/FormApi.ts index ff46178cd..f7762b40d 100644 --- a/packages/form-core/src/FormApi.ts +++ b/packages/form-core/src/FormApi.ts @@ -265,6 +265,7 @@ export interface FormListeners< > fieldApi: AnyFieldApi }) => void + onChangeDebounceMs?: number onBlur?: (props: { formApi: FormApi< @@ -281,6 +282,7 @@ export interface FormListeners< > fieldApi: AnyFieldApi }) => void + onBlurDebounceMs?: number onMount?: (props: { formApi: FormApi< diff --git a/packages/form-core/tests/FormApi.spec.ts b/packages/form-core/tests/FormApi.spec.ts index b380a63cb..ac151676a 100644 --- a/packages/form-core/tests/FormApi.spec.ts +++ b/packages/form-core/tests/FormApi.spec.ts @@ -2064,6 +2064,39 @@ describe('form api', () => { expect(arr).toStrictEqual(['middle', 'end', 'start']) }) + it('should debounce onChange listener', async () => { + vi.useFakeTimers() + const onChangeMock = vi.fn() + + const form = new FormApi({ + defaultValues: { + name: '', + }, + listeners: { + onChange: onChangeMock, + onChangeDebounceMs: 500, + }, + }) + form.mount() + + const field = new FieldApi({ + form, + name: 'name', + }) + field.mount() + + field.handleChange('first') + field.handleChange('second') + expect(onChangeMock).not.toHaveBeenCalled() + + await vi.advanceTimersByTimeAsync(500) + expect(onChangeMock).toHaveBeenCalledTimes(1) + expect(onChangeMock).toHaveBeenCalledWith({ + formApi: form, + fieldApi: field, + }) + }) + it('should run the form listener onBlur', async () => { let fieldApiCheck!: AnyFieldApi let formApiCheck!: AnyFormApi @@ -2094,6 +2127,35 @@ describe('form api', () => { expect(formApiCheck.state.values.name).toStrictEqual('test') }) + it('should debounce onBlur listener', async () => { + vi.useFakeTimers() + const onBlurMock = vi.fn() + + const form = new FormApi({ + defaultValues: { + name: '', + }, + listeners: { + onBlur: onBlurMock, + onBlurDebounceMs: 500, + }, + }) + form.mount() + + const field = new FieldApi({ + form, + name: 'name', + }) + field.mount() + + field.handleBlur() + field.handleBlur() + expect(onBlurMock).not.toHaveBeenCalled() + + await vi.advanceTimersByTimeAsync(500) + expect(onBlurMock).toHaveBeenCalledTimes(1) + }) + it('should run the field listener onSubmit', async () => { const form = new FormApi({ defaultValues: {