Skip to content
Open
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
46 changes: 40 additions & 6 deletions packages/form-core/src/FormApi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1589,15 +1589,49 @@ export class FormApi<
) => {
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
const fieldInstance = this.fieldInfo[field]?.instance
if (!fieldInstance) return []

// If the field is not touched (same logic as in validateAllFields)
if (!fieldInstance.state.meta.isTouched) {
// Mark it as touched
fieldInstance.setMeta((prev) => ({ ...prev, isTouched: true }))
// If there's a mounted field instance, delegate to the instance (normal flow)
if (fieldInstance) {
// If the field is not touched (same logic as in validateAllFields)
if (!fieldInstance.state.meta.isTouched) {
// Mark it as touched
fieldInstance.setMeta((prev) => ({ ...prev, isTouched: true }))
}

return fieldInstance.validate(cause)
}

// No mounted field instance: ensure we have base meta for the field
if (this.baseStore.state.fieldMetaBase[field] === undefined) {
this.setFieldMeta(field, () => defaultFieldMeta)
}

// If the field is not touched, mark as touched in base meta
const baseMeta = this.baseStore.state.fieldMetaBase[field]
if (!baseMeta?.isTouched) {
this.setFieldMeta(field, (prev) => ({ ...prev, isTouched: true }))
}

// Run form-level synchronous validation which will update field metas
const { fieldsErrorMap } = this.validateSync(cause)

const fieldErrorMap = (fieldsErrorMap as any)?.[field] ?? {}
const hasSyncErrored = Object.values(fieldErrorMap).some(
(v) => v !== undefined,
)

// If sync validators found errors and asyncAlways is not set, return current errors
if (hasSyncErrored && !(this.options.asyncAlways as boolean)) {
const meta = this.getFieldMeta(field)
return (meta?.errors ?? []) as ValidationError[]
}

return fieldInstance.validate(cause)
// Otherwise, run async validators and return errors for this field
return (async () => {
const asyncFieldErrors = await this.validateAsync(cause)
const meta = this.getFieldMeta(field)
return (meta?.errors ?? []) as ValidationError[]
})()
}

/**
Expand Down
34 changes: 34 additions & 0 deletions packages/form-core/tests/unmountedField.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import { describe, expect, it } from 'vitest'
import { FormApi } from '../src/index'

describe('unmounted field validation', () => {
it('clears validation when setFieldValue is used for an unmounted field', async () => {
const form = new FormApi<string | any>({
validators: {
onChange: ({ value }) => {
if (!value || !value.name) {
return { fields: { name: 'Required' } }
}
return
},
},
})

form.mount()

// populate initial errors
await form.validateAllFields('change')

const before = form.getFieldMeta('name')
expect(before?.errors).toContain('Required')

// Programmatically set the value for a field that has no mounted FieldApi
form.setFieldValue('name', 'now valid')

// allow microtask queue to flush (validation may run sync or async paths)
await Promise.resolve()

const after = form.getFieldMeta('name')
expect(after?.errors).toEqual([])
})
})