Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions .changeset/fix-nested-errors-on-manual-validate.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
---
'@tanstack/form-core': patch
'@tanstack/react-form': patch
'@tanstack/angular-form': patch
'@tanstack/vue-form': patch
'@tanstack/solid-form': patch
---

fix: flatten errors consistently when validating before field mount

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.
2 changes: 1 addition & 1 deletion packages/form-core/src/FormApi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1083,7 +1083,7 @@ export class FormApi<
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
const fieldInstance = this.getFieldInfo(fieldName)?.instance

if (fieldInstance && !fieldInstance.options.disableErrorFlat) {
if (!fieldInstance || !fieldInstance.options.disableErrorFlat) {
fieldErrors = fieldErrors.flat(1)
}
}
Expand Down
119 changes: 119 additions & 0 deletions packages/form-core/tests/FieldApi.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2638,6 +2638,125 @@ describe('field api', () => {
expect(field3.state.meta.errors).toContain('Field 3 error')
vi.useRealTimers()
})

it('should flatten errors when manually calling form.validate() before field mount', async () => {
vi.useFakeTimers()
const form = new FormApi({
defaultValues: {
name: '',
},
validators: {
onChange: ({ value }) => {
if (!value.name) {
return {
fields: {
name: 'Name is required',
},
}
}
return undefined
},
},
})

form.mount()

// Manually validate BEFORE field mount
await form.validate('change')

// Now mount the field
const field = new FieldApi({ form, name: 'name' })
field.mount()

// Errors should be flattened [error], not [[error]]
expect(field.state.meta.errors).toEqual(['Name is required'])

vi.useRealTimers()
})

it('should flatten Zod errors when manually calling form.validate() before field mount (Issue #1993)', async () => {
vi.useFakeTimers()

// Exact scenario from issue #1993
const form = new FormApi({
defaultValues: {
show: false,
firstName: '',
lastName: '',
},
validators: {
onChange: z.object({
show: z.boolean(),
firstName: z.string().min(1, 'First name required'),
lastName: z.string().min(1, 'Last name required'),
}),
},
})

form.mount()

// Simulate checkbox onChange that triggers validation BEFORE conditional fields mount
// This is the exact bug scenario from issue #1993
await form.validate('change')

// Now mount the conditional field (like when show becomes true)
const firstNameField = new FieldApi({ form, name: 'firstName' })
firstNameField.mount()

// Errors should be flattened array of Zod error objects
// NOT: [[{ code: "too_small", message: "..." }]]
// BUT: [{ code: "too_small", message: "..." }]
expect(Array.isArray(firstNameField.state.meta.errors)).toBe(true)
expect(Array.isArray(firstNameField.state.meta.errors[0])).toBe(false)

// Should be able to access .message directly
expect(firstNameField.state.meta.errors[0]).toHaveProperty('message')
expect(firstNameField.state.meta.errors[0]).toHaveProperty('code')

vi.useRealTimers()
})

it('should respect disableErrorFlat option for mounted fields', async () => {
vi.useFakeTimers()
const form = new FormApi({
defaultValues: {
name: '',
},
validators: {
onChange: ({ value }) => {
if (!value.name) {
return {
fields: {
name: [['Error level 1', 'Error level 2']],
},
}
}
return undefined
},
},
})

form.mount()

// Mount field with disableErrorFlat: true FIRST
const field = new FieldApi({
form,
name: 'name',
disableErrorFlat: true,
})
field.mount()

// Trigger validation after mount
field.setValue('')
await vi.advanceTimersByTimeAsync(50)

// Errors should NOT be flattened when disableErrorFlat is true
expect(field.state.meta.errors).toEqual([
[['Error level 1', 'Error level 2']],
])

vi.useRealTimers()
})
})

describe('deleteField functionality', () => {
Expand Down
Loading