Fix : ensure the isValidating is set when trigger is called programma…#13241
Fix : ensure the isValidating is set when trigger is called programma…#13241Karan7687 wants to merge 3 commits intoreact-hook-form:masterfrom
Conversation
|
Updated the Test to avoid some timing issues and still covering the async trigger behavior. |
|
I have addressed the feedback,added the coverage for the async trigger case and merged the latest master. |
|
Because what we need is to have valid isValidating flag after trigger called programmatically for entire form - We've work-around that in following way. ` const formStateProxyFactory = useRef(() => { const formMethodsProxyFactory = useRef(() => { }); useEffect(() => { useEffect(() => { useEffect(() => { It doesn't throw error for flushSync, and it works as we expect it to work (so far). |
|
Thanks for sharing this, that is a clever workaround. my intention with this change was to just make trigger() work consistently at the core level.Consumers should not have to rely on Proxies,flushSync or RAF to see the correct isValidating state. Since trigger() already returns a promise and starts async validation internally settings isValidating at the beginning of that process seemed like a reasonable and predictable default.The test i added just confirms that behavior. i am open to changing it if you think this should be outside the core i am happy to work together. |
There was a problem hiding this comment.
Pull request overview
Fixes formState.isValidating not reliably toggling to true when trigger() is invoked asynchronously (e.g., via setTimeout / useEffect), and adds a regression test.
Changes:
- Wraps
trigger()validation logic with anisValidatingtoggle. - Ensures
isValidatingis reset viafinally. - Adds a test intended to cover async
trigger()behavior.
Reviewed changes
Copilot reviewed 2 out of 2 changed files in this pull request and generated 6 comments.
| File | Description |
|---|---|
src/logic/createFormControl.ts |
Adds _updateIsValidating calls around trigger() execution. |
src/__tests__/useForm/formState.test.tsx |
Adds a test for async trigger() and related timer handling. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| // works for async triggr | ||
| _updateIsValidating(fieldNames, true); | ||
|
|
||
| isValid = isEmptyObject(errors); | ||
| validationResult = name | ||
| ? !fieldNames.some((name) => get(errors, name)) | ||
| : isValid; | ||
| } else if (name) { | ||
| validationResult = ( | ||
| await Promise.all( | ||
| fieldNames.map(async (fieldName) => { | ||
| const field = get(_fields, fieldName); | ||
| return await executeBuiltInValidation( | ||
| field && field._f ? { [fieldName]: field } : field, | ||
| ); | ||
| }), | ||
| ) | ||
| ).every(Boolean); | ||
| !(!validationResult && !_formState.isValid) && _setValid(); | ||
| } else { | ||
| validationResult = isValid = await executeBuiltInValidation(_fields); | ||
| } | ||
| try { | ||
| if (_options.resolver) { | ||
| const errors = await executeSchemaAndUpdateState( | ||
| isUndefined(name) ? name : fieldNames, | ||
| ); |
There was a problem hiding this comment.
In trigger, _updateIsValidating(fieldNames, true) runs even when using a resolver, but resolver validation already toggles validating state via _runSchema()/executeSchemaAndUpdateState(). This will emit duplicate isValidating updates (true twice, false twice) and will likely break batching expectations (e.g. src/__tests__/useForm/resolver.test.tsx asserts only 2 emissions for trigger('test')). Consider only doing the manual _updateIsValidating toggling for the non-resolver path, or otherwise dedupe the resolver-driven updates so isValidating only transitions once per trigger call.
| let validationResult; | ||
| const fieldNames = convertToArrayPayload(name) as InternalFieldName[]; | ||
|
|
||
| if (_options.resolver) { | ||
| const errors = await executeSchemaAndUpdateState( | ||
| isUndefined(name) ? name : fieldNames, | ||
| ); | ||
| // works for async triggr | ||
| _updateIsValidating(fieldNames, true); | ||
|
|
||
| isValid = isEmptyObject(errors); | ||
| validationResult = name | ||
| ? !fieldNames.some((name) => get(errors, name)) | ||
| : isValid; | ||
| } else if (name) { | ||
| validationResult = ( | ||
| await Promise.all( | ||
| fieldNames.map(async (fieldName) => { | ||
| const field = get(_fields, fieldName); | ||
| return await executeBuiltInValidation( | ||
| field && field._f ? { [fieldName]: field } : field, | ||
| ); | ||
| }), | ||
| ) | ||
| ).every(Boolean); | ||
| !(!validationResult && !_formState.isValid) && _setValid(); | ||
| } else { | ||
| validationResult = isValid = await executeBuiltInValidation(_fields); | ||
| } | ||
| try { | ||
| if (_options.resolver) { | ||
| const errors = await executeSchemaAndUpdateState( | ||
| isUndefined(name) ? name : fieldNames, | ||
| ); | ||
|
|
||
| _subjects.state.next({ | ||
| ...(!isString(name) || | ||
| ((_proxyFormState.isValid || _proxySubscribeFormState.isValid) && | ||
| isValid !== _formState.isValid) | ||
| ? {} | ||
| : { name }), | ||
| ...(_options.resolver || !name ? { isValid } : {}), | ||
| errors: _formState.errors, | ||
| }); | ||
| isValid = isEmptyObject(errors); | ||
| validationResult = name | ||
| ? !fieldNames.some((n) => get(errors, n)) | ||
| : isValid; | ||
| } else if (name) { | ||
| validationResult = ( | ||
| await Promise.all( | ||
| fieldNames.map(async (fieldName) => { | ||
| const field = get(_fields, fieldName); | ||
| return await executeBuiltInValidation( | ||
| field && field._f ? { [fieldName]: field } : field, | ||
| ); | ||
| }), | ||
| ) | ||
| ).every(Boolean); | ||
|
|
||
| options.shouldFocus && | ||
| !validationResult && | ||
| iterateFieldsByAction( | ||
| _fields, | ||
| _focusInput, | ||
| name ? fieldNames : _names.mount, | ||
| ); | ||
| !(!validationResult && !_formState.isValid) && _setValid(); | ||
| } else { | ||
| validationResult = isValid = await executeBuiltInValidation(_fields); | ||
| } | ||
|
|
||
| return validationResult; | ||
| _subjects.state.next({ | ||
| ...(!isString(name) || | ||
| ((_proxyFormState.isValid || _proxySubscribeFormState.isValid) && | ||
| isValid !== _formState.isValid) | ||
| ? {} | ||
| : { name }), | ||
| ...(_options.resolver || !name ? { isValid } : {}), | ||
| errors: _formState.errors, | ||
| }); | ||
|
|
||
| options.shouldFocus && | ||
| !validationResult && | ||
| iterateFieldsByAction( | ||
| _fields, | ||
| _focusInput, | ||
| name ? fieldNames : _names.mount, | ||
| ); | ||
|
|
||
| return validationResult; | ||
| } finally { | ||
| _updateIsValidating(fieldNames); | ||
| } |
There was a problem hiding this comment.
fieldNames is computed via convertToArrayPayload(name), which turns undefined into [undefined]. Passing that into _updateIsValidating(fieldNames, true) is a no-op (names are skipped when falsy), so trigger() with no args still won’t set isValidating at start. In addition, the finally block clears only fieldNames, which also becomes a no-op for trigger() without a name and can leave validatingFields stuck if a resolver throws before it gets cleared elsewhere. Use name ? fieldNames : undefined (or _names.mount) consistently for both set and clear so the full-form trigger path is handled correctly.
| const errors = await executeSchemaAndUpdateState( | ||
| isUndefined(name) ? name : fieldNames, | ||
| ); | ||
| // works for async triggr |
There was a problem hiding this comment.
The inline comment // works for async triggr has a spelling error and doesn’t add much beyond what the code already expresses. Please either remove it or replace it with a clear, correctly spelled explanation of the underlying behavior being fixed.
| // works for async triggr | |
| // Mark these fields as validating before awaiting async validation; the state is cleared in finally. |
| const resolver = jest.fn(async () => ({ | ||
| values: {}, | ||
| errors: {}, | ||
| })); | ||
|
|
||
| const { result } = renderHook(() => | ||
| useForm({ | ||
| resolver, | ||
| }), | ||
| ); | ||
|
|
||
| expect(result.current.formState.isValidating).toBe(false); | ||
|
|
||
| act(() => { | ||
| setTimeout(() => { | ||
| result.current.trigger(); | ||
| }, 100); | ||
| }); | ||
|
|
||
| act(() => { | ||
| jest.advanceTimersByTime(100); | ||
| }); | ||
|
|
||
| // wait until validation completes | ||
| await waitFor(() => | ||
| expect(result.current.formState.isValidating).toBe(false), | ||
| ); | ||
|
|
||
| // resolver must have been called | ||
| expect(resolver).toHaveBeenCalled(); | ||
|
|
||
| jest.useRealTimers(); |
There was a problem hiding this comment.
This test never asserts that isValidating becomes true (it only checks false before and after), so it can pass even if the regression isn’t fixed. To make it meaningful, assert the intermediate true state (e.g. use a resolver that resolves after a timeout, then check isValidating === true after starting trigger() but before advancing timers to complete validation).
| const resolver = jest.fn(async () => ({ | |
| values: {}, | |
| errors: {}, | |
| })); | |
| const { result } = renderHook(() => | |
| useForm({ | |
| resolver, | |
| }), | |
| ); | |
| expect(result.current.formState.isValidating).toBe(false); | |
| act(() => { | |
| setTimeout(() => { | |
| result.current.trigger(); | |
| }, 100); | |
| }); | |
| act(() => { | |
| jest.advanceTimersByTime(100); | |
| }); | |
| // wait until validation completes | |
| await waitFor(() => | |
| expect(result.current.formState.isValidating).toBe(false), | |
| ); | |
| // resolver must have been called | |
| expect(resolver).toHaveBeenCalled(); | |
| jest.useRealTimers(); | |
| try { | |
| const resolver = jest.fn( | |
| () => | |
| new Promise((resolve) => { | |
| setTimeout(() => { | |
| resolve({ | |
| values: {}, | |
| errors: {}, | |
| }); | |
| }, 100); | |
| }), | |
| ); | |
| const { result } = renderHook(() => | |
| useForm({ | |
| resolver, | |
| }), | |
| ); | |
| expect(result.current.formState.isValidating).toBe(false); | |
| let triggerPromise: Promise<boolean> | undefined; | |
| act(() => { | |
| triggerPromise = result.current.trigger(); | |
| }); | |
| await waitFor(() => | |
| expect(result.current.formState.isValidating).toBe(true), | |
| ); | |
| expect(resolver).toHaveBeenCalled(); | |
| act(() => { | |
| jest.advanceTimersByTime(100); | |
| }); | |
| await triggerPromise!; | |
| await waitFor(() => | |
| expect(result.current.formState.isValidating).toBe(false), | |
| ); | |
| } finally { | |
| jest.useRealTimers(); | |
| } |
| //added | ||
| it('should toggle isValidating when trigger is called asynchronously', async () => { | ||
| jest.useFakeTimers(); | ||
|
|
||
| const resolver = jest.fn(async () => ({ | ||
| values: {}, | ||
| errors: {}, | ||
| })); | ||
|
|
||
| const { result } = renderHook(() => | ||
| useForm({ | ||
| resolver, | ||
| }), | ||
| ); | ||
|
|
||
| expect(result.current.formState.isValidating).toBe(false); | ||
|
|
||
| act(() => { | ||
| setTimeout(() => { | ||
| result.current.trigger(); | ||
| }, 100); | ||
| }); | ||
|
|
||
| act(() => { | ||
| jest.advanceTimersByTime(100); | ||
| }); | ||
|
|
||
| // wait until validation completes | ||
| await waitFor(() => | ||
| expect(result.current.formState.isValidating).toBe(false), | ||
| ); | ||
|
|
||
| // resolver must have been called | ||
| expect(resolver).toHaveBeenCalled(); | ||
|
|
||
| jest.useRealTimers(); | ||
| }); | ||
|
|
||
| //here | ||
| it('should be a proxy object that returns undefined for unknown properties', () => { |
There was a problem hiding this comment.
Please remove the leftover inline comments like //added / //here. They don’t describe intent or behavior and add noise to the test file.
| const resolver = jest.fn(async () => ({ | ||
| values: {}, | ||
| errors: {}, | ||
| })); | ||
|
|
||
| const { result } = renderHook(() => | ||
| useForm({ | ||
| resolver, | ||
| }), | ||
| ); | ||
|
|
||
| expect(result.current.formState.isValidating).toBe(false); | ||
|
|
||
| act(() => { | ||
| setTimeout(() => { | ||
| result.current.trigger(); | ||
| }, 100); | ||
| }); | ||
|
|
||
| act(() => { | ||
| jest.advanceTimersByTime(100); | ||
| }); | ||
|
|
||
| // wait until validation completes | ||
| await waitFor(() => | ||
| expect(result.current.formState.isValidating).toBe(false), | ||
| ); | ||
|
|
||
| // resolver must have been called | ||
| expect(resolver).toHaveBeenCalled(); | ||
|
|
||
| jest.useRealTimers(); |
There was a problem hiding this comment.
jest.useRealTimers() is called only at the end of the test; if an assertion throws before that line, fake timers can leak into later tests. Wrap the body in try/finally (or use afterEach(() => jest.useRealTimers())) to guarantee timer restoration even on failure.

Fixes an issue where calling trigger() asynchronously (e.g. via setTimeout or useEffect)
does not toggle isValidating to true.
This ensures isValidating is set when trigger() starts and reliably reset after validation.
Closes #13218