diff --git a/packages/form-core/src/FormApi.ts b/packages/form-core/src/FormApi.ts index e7e0dd08b..245d11120 100644 --- a/packages/form-core/src/FormApi.ts +++ b/packages/form-core/src/FormApi.ts @@ -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[] + })() } /** diff --git a/packages/form-core/tests/unmountedField.spec.ts b/packages/form-core/tests/unmountedField.spec.ts new file mode 100644 index 000000000..d2ed02ad2 --- /dev/null +++ b/packages/form-core/tests/unmountedField.spec.ts @@ -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({ + 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([]) + }) +})