Skip to content

Commit 41faffe

Browse files
fix: flatten errors consistently when validating before field mount (#2003)
* fix: flatten errors consistently when validating before field mount * ci: apply automated fixes and generate docs --------- Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
1 parent 38f2e5d commit 41faffe

3 files changed

Lines changed: 131 additions & 1 deletion

File tree

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
---
2+
'@tanstack/form-core': patch
3+
'@tanstack/react-form': patch
4+
'@tanstack/angular-form': patch
5+
'@tanstack/vue-form': patch
6+
'@tanstack/solid-form': patch
7+
---
8+
9+
fix: flatten errors consistently when validating before field mount
10+
11+
Fixed an issue where `field.errors` was incorrectly nested as `[[error]]` instead of `[error]` when `form.validate()` was called manually before a field was mounted. The `flat(1)` operation is now applied by default unless `disableErrorFlat` is explicitly set to true, ensuring consistent error structure regardless of when validation occurs.

packages/form-core/src/FormApi.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1083,7 +1083,7 @@ export class FormApi<
10831083
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
10841084
const fieldInstance = this.getFieldInfo(fieldName)?.instance
10851085

1086-
if (fieldInstance && !fieldInstance.options.disableErrorFlat) {
1086+
if (!fieldInstance || !fieldInstance.options.disableErrorFlat) {
10871087
fieldErrors = fieldErrors.flat(1)
10881088
}
10891089
}

packages/form-core/tests/FieldApi.spec.ts

Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2638,6 +2638,125 @@ describe('field api', () => {
26382638
expect(field3.state.meta.errors).toContain('Field 3 error')
26392639
vi.useRealTimers()
26402640
})
2641+
2642+
it('should flatten errors when manually calling form.validate() before field mount', async () => {
2643+
vi.useFakeTimers()
2644+
const form = new FormApi({
2645+
defaultValues: {
2646+
name: '',
2647+
},
2648+
validators: {
2649+
onChange: ({ value }) => {
2650+
if (!value.name) {
2651+
return {
2652+
fields: {
2653+
name: 'Name is required',
2654+
},
2655+
}
2656+
}
2657+
return undefined
2658+
},
2659+
},
2660+
})
2661+
2662+
form.mount()
2663+
2664+
// Manually validate BEFORE field mount
2665+
await form.validate('change')
2666+
2667+
// Now mount the field
2668+
const field = new FieldApi({ form, name: 'name' })
2669+
field.mount()
2670+
2671+
// Errors should be flattened [error], not [[error]]
2672+
expect(field.state.meta.errors).toEqual(['Name is required'])
2673+
2674+
vi.useRealTimers()
2675+
})
2676+
2677+
it('should flatten Zod errors when manually calling form.validate() before field mount (Issue #1993)', async () => {
2678+
vi.useFakeTimers()
2679+
2680+
// Exact scenario from issue #1993
2681+
const form = new FormApi({
2682+
defaultValues: {
2683+
show: false,
2684+
firstName: '',
2685+
lastName: '',
2686+
},
2687+
validators: {
2688+
onChange: z.object({
2689+
show: z.boolean(),
2690+
firstName: z.string().min(1, 'First name required'),
2691+
lastName: z.string().min(1, 'Last name required'),
2692+
}),
2693+
},
2694+
})
2695+
2696+
form.mount()
2697+
2698+
// Simulate checkbox onChange that triggers validation BEFORE conditional fields mount
2699+
// This is the exact bug scenario from issue #1993
2700+
await form.validate('change')
2701+
2702+
// Now mount the conditional field (like when show becomes true)
2703+
const firstNameField = new FieldApi({ form, name: 'firstName' })
2704+
firstNameField.mount()
2705+
2706+
// Errors should be flattened array of Zod error objects
2707+
// NOT: [[{ code: "too_small", message: "..." }]]
2708+
// BUT: [{ code: "too_small", message: "..." }]
2709+
expect(Array.isArray(firstNameField.state.meta.errors)).toBe(true)
2710+
expect(Array.isArray(firstNameField.state.meta.errors[0])).toBe(false)
2711+
2712+
// Should be able to access .message directly
2713+
expect(firstNameField.state.meta.errors[0]).toHaveProperty('message')
2714+
expect(firstNameField.state.meta.errors[0]).toHaveProperty('code')
2715+
2716+
vi.useRealTimers()
2717+
})
2718+
2719+
it('should respect disableErrorFlat option for mounted fields', async () => {
2720+
vi.useFakeTimers()
2721+
const form = new FormApi({
2722+
defaultValues: {
2723+
name: '',
2724+
},
2725+
validators: {
2726+
onChange: ({ value }) => {
2727+
if (!value.name) {
2728+
return {
2729+
fields: {
2730+
name: [['Error level 1', 'Error level 2']],
2731+
},
2732+
}
2733+
}
2734+
return undefined
2735+
},
2736+
},
2737+
})
2738+
2739+
form.mount()
2740+
2741+
// Mount field with disableErrorFlat: true FIRST
2742+
const field = new FieldApi({
2743+
form,
2744+
name: 'name',
2745+
disableErrorFlat: true,
2746+
})
2747+
field.mount()
2748+
2749+
// Trigger validation after mount
2750+
field.setValue('')
2751+
await vi.advanceTimersByTimeAsync(50)
2752+
2753+
// Errors should NOT be flattened when disableErrorFlat is true
2754+
expect(field.state.meta.errors).toEqual([
2755+
[['Error level 1', 'Error level 2']],
2756+
])
2757+
2758+
vi.useRealTimers()
2759+
})
26412760
})
26422761

26432762
describe('deleteField functionality', () => {

0 commit comments

Comments
 (0)