Skip to content

Commit b2e169c

Browse files
erikras-richard-agentErik Rasmussen
andauthored
Fix #176: Await async validate to prevent Promise as ARRAY_ERROR (#193)
* Fix #176: Await async validate to prevent Promise as ARRAY_ERROR Fixes #176 ## Problem When FieldArray's validate prop was an async function, the unresolved Promise was being set as ARRAY_ERROR instead of waiting for it to resolve. This caused: - Promise objects stored in form errors - Form appearing invalid even when validation passed - Unable to check for actual errors vs pending validations ## Root Cause The validate wrapper in useFieldArray.ts was not async, so when validateProp returned a Promise, the code checked the Promise object itself (not undefined/array), wrapped it in an array, and set it as ARRAY_ERROR. ## Solution Made the validate function async and await Promise.resolve() before checking error type: ```typescript // Before const error = validateProp(value, allValues, meta) // After const error = await Promise.resolve(validateProp(value, allValues, meta)) ``` Promise.resolve() handles both sync and async validators correctly: - Sync validators: returns value immediately - Async validators: awaits the promise ## Tests Added useFieldArray.async-validate-176.test.tsx with regression tests: - ✅ Async validator resolving to undefined (no error) - ✅ Async validator resolving to error string - ✅ Verifies Promise is NOT stored as ARRAY_ERROR ## Credit Solution provided by issue reporter in #176 comments. cc @erikras * Fix: Split async/sync validate tests to avoid useConstant issue The original test tried to switch validators via rerender, but useConstant locks the validator on first render. Split into two independent tests. * Fix: Handle both sync and async validators correctly The previous implementation wrapped all validators in async/await, which caused synchronous validators to return Promises. This broke timing in tests and potentially in production code that expected immediate results. Now we check if the validator result is a Promise before awaiting it: - Async validators (return Promise): await and process result - Sync validators (return value): process immediately All tests passing ✓ * Address CodeRabbit review comments - Hoist sleep helper to describe scope (DRY) - Make waitFor assertions unconditional for proper retry behavior - Remove duplicate sleep declaration in third test All tests still passing (38/38 ✓) * Fix CodeRabbit consistency issue Use captured error variable consistently in waitFor assertions --------- Co-authored-by: Erik Rasmussen <erik@mini.local>
1 parent 31808dd commit b2e169c

File tree

2 files changed

+174
-1
lines changed

2 files changed

+174
-1
lines changed
Lines changed: 156 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,156 @@
1+
import React from 'react'
2+
import { render, waitFor } from '@testing-library/react'
3+
import { Form } from 'react-final-form'
4+
import arrayMutators from 'final-form-arrays'
5+
import { useFieldArray } from '.'
6+
import { ARRAY_ERROR } from 'final-form'
7+
8+
// Helper function for async delays
9+
const sleep = (ms: number) => new Promise(resolve => setTimeout(resolve, ms))
10+
11+
describe('useFieldArray async validate regression #176', () => {
12+
it('should await async validate and not store Promise as ARRAY_ERROR', async () => {
13+
14+
const asyncValidate = jest.fn(async (value: any[]) => {
15+
await sleep(10)
16+
// Return undefined (no error) after async resolution
17+
return undefined
18+
})
19+
20+
let formState: any
21+
22+
const TestComponent = () => {
23+
const { fields } = useFieldArray('items', {
24+
validate: asyncValidate
25+
})
26+
return (
27+
<div>
28+
{fields.map((name, index) => (
29+
<div key={name}>Item {index}</div>
30+
))}
31+
</div>
32+
)
33+
}
34+
35+
render(
36+
<Form
37+
onSubmit={() => {}}
38+
mutators={arrayMutators}
39+
initialValues={{ items: ['a', 'b', 'c'] }}
40+
render={({ form }) => {
41+
formState = form.getState()
42+
return <TestComponent />
43+
}}
44+
/>
45+
)
46+
47+
// Initial render - validator should be called
48+
await waitFor(() => {
49+
expect(asyncValidate).toHaveBeenCalled()
50+
})
51+
52+
// Wait for async validation to complete
53+
await waitFor(() => {
54+
// The error should NOT be a Promise
55+
const error = formState.errors?.items
56+
expect(error).not.toBeInstanceOf(Promise)
57+
58+
// Check ARRAY_ERROR specifically if it's an array
59+
if (Array.isArray(error)) {
60+
const arrayError = (error as any)[ARRAY_ERROR]
61+
expect(arrayError).not.toBeInstanceOf(Promise)
62+
}
63+
})
64+
65+
// Final state check - no errors (3 items, validate returns undefined)
66+
expect(formState.errors?.items).toBeUndefined()
67+
})
68+
69+
it('should handle sync validate without breaking', () => {
70+
const syncValidate = jest.fn((value: any[]) => {
71+
return value && value.length < 2 ? 'Need at least 2 items' : undefined
72+
})
73+
74+
let formState: any
75+
76+
const TestComponent = () => {
77+
const { fields } = useFieldArray('items', {
78+
validate: syncValidate
79+
})
80+
return (
81+
<div>
82+
{fields.map((name, index) => (
83+
<div key={name}>Item {index}</div>
84+
))}
85+
</div>
86+
)
87+
}
88+
89+
render(
90+
<Form
91+
onSubmit={() => {}}
92+
mutators={arrayMutators}
93+
initialValues={{ items: ['a', 'b', 'c'] }}
94+
render={({ form }) => {
95+
formState = form.getState()
96+
return <TestComponent />
97+
}}
98+
/>
99+
)
100+
101+
// Sync validator should be called
102+
expect(syncValidate).toHaveBeenCalled()
103+
104+
// Sync errors should work normally
105+
expect(formState.errors?.items).toBeUndefined() // 3 items, so no error
106+
})
107+
108+
it('should properly handle async validation errors', async () => {
109+
const asyncValidateWithError = jest.fn(async (value: any[]) => {
110+
await sleep(10)
111+
return value && value.length < 5 ? 'Need at least 5 items' : undefined
112+
})
113+
114+
let formState: any
115+
116+
const TestComponent = () => {
117+
const { fields } = useFieldArray('items', {
118+
validate: asyncValidateWithError
119+
})
120+
return (
121+
<div>
122+
{fields.map((name, index) => (
123+
<div key={name}>Item {index}</div>
124+
))}
125+
</div>
126+
)
127+
}
128+
129+
render(
130+
<Form
131+
onSubmit={() => {}}
132+
mutators={arrayMutators}
133+
initialValues={{ items: ['a', 'b', 'c'] }}
134+
render={({ form }) => {
135+
formState = form.getState()
136+
return <TestComponent />
137+
}}
138+
/>
139+
)
140+
141+
// Wait for async validation
142+
await waitFor(() => {
143+
expect(asyncValidateWithError).toHaveBeenCalled()
144+
})
145+
146+
// Error should be resolved, not a Promise
147+
await waitFor(() => {
148+
const error = formState.errors?.items
149+
expect(error).toBeDefined()
150+
expect(Array.isArray(error)).toBe(true)
151+
const arrayError = (error as any)[ARRAY_ERROR]
152+
expect(arrayError).toBe('Need at least 5 items')
153+
expect(arrayError).not.toBeInstanceOf(Promise)
154+
})
155+
})
156+
})

src/useFieldArray.ts

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,24 @@ const useFieldArray = (
4545
!validateProp
4646
? undefined
4747
: (value: any, allValues: any, meta: any) => {
48-
const error = validateProp(value, allValues, meta)
48+
const rawError = validateProp(value, allValues, meta)
49+
50+
// If the validator returned a Promise, await it before processing
51+
if (rawError && typeof rawError.then === 'function') {
52+
return rawError.then((error: any) => {
53+
if (!error || Array.isArray(error)) {
54+
return error
55+
} else {
56+
const arrayError: any[] = []
57+
// gross, but we have to set a string key on the array
58+
; (arrayError as any)[ARRAY_ERROR] = error
59+
return arrayError
60+
}
61+
})
62+
}
63+
64+
// Synchronous validator - process immediately
65+
const error = rawError
4966
if (!error || Array.isArray(error)) {
5067
return error
5168
} else {

0 commit comments

Comments
 (0)