From 86447baba2b8dba669cfbc87fa83c90a6ff8c0c8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=ED=99=8D=EA=B7=9C=EC=A7=84?= Date: Fri, 23 Jan 2026 01:04:07 +0900 Subject: [PATCH 1/2] fix: flatten errors consistently when validating before field mount --- .../fix-nested-errors-on-manual-validate.md | 11 ++ packages/form-core/src/FormApi.ts | 2 +- packages/form-core/tests/FieldApi.spec.ts | 117 ++++++++++++++++++ 3 files changed, 129 insertions(+), 1 deletion(-) create mode 100644 .changeset/fix-nested-errors-on-manual-validate.md diff --git a/.changeset/fix-nested-errors-on-manual-validate.md b/.changeset/fix-nested-errors-on-manual-validate.md new file mode 100644 index 000000000..680b0f146 --- /dev/null +++ b/.changeset/fix-nested-errors-on-manual-validate.md @@ -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. diff --git a/packages/form-core/src/FormApi.ts b/packages/form-core/src/FormApi.ts index ff52bbe74..f592f226b 100644 --- a/packages/form-core/src/FormApi.ts +++ b/packages/form-core/src/FormApi.ts @@ -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) } } diff --git a/packages/form-core/tests/FieldApi.spec.ts b/packages/form-core/tests/FieldApi.spec.ts index abca29868..542fa5a02 100644 --- a/packages/form-core/tests/FieldApi.spec.ts +++ b/packages/form-core/tests/FieldApi.spec.ts @@ -2638,6 +2638,123 @@ 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', () => { From b1b17cf581b7d1c81636cab5c388ee7cea8a9ee4 Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Thu, 22 Jan 2026 17:34:20 +0000 Subject: [PATCH 2/2] ci: apply automated fixes and generate docs --- packages/form-core/tests/FieldApi.spec.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/form-core/tests/FieldApi.spec.ts b/packages/form-core/tests/FieldApi.spec.ts index 542fa5a02..d4c9ce3fe 100644 --- a/packages/form-core/tests/FieldApi.spec.ts +++ b/packages/form-core/tests/FieldApi.spec.ts @@ -2751,7 +2751,9 @@ describe('field api', () => { 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']]]) + expect(field.state.meta.errors).toEqual([ + [['Error level 1', 'Error level 2']], + ]) vi.useRealTimers() })