diff --git a/packages/form-core/src/standardSchemaValidator.ts b/packages/form-core/src/standardSchemaValidator.ts index c0c5bde69..ae4b93c35 100644 --- a/packages/form-core/src/standardSchemaValidator.ts +++ b/packages/form-core/src/standardSchemaValidator.ts @@ -19,21 +19,40 @@ export type TStandardSchemaValidatorIssue< ? StandardSchemaV1Issue[] : never -function prefixSchemaToErrors(issues: readonly StandardSchemaV1Issue[]) { +function prefixSchemaToErrors( + issues: readonly StandardSchemaV1Issue[], + formValue: unknown, +) { const schema = new Map() for (const issue of issues) { - const path = [...(issue.path ?? [])] - .map((segment) => { - const normalizedSegment = - typeof segment === 'object' ? segment.key : segment - return typeof normalizedSegment === 'number' - ? `[${normalizedSegment}]` - : normalizedSegment - }) - .join('.') - .replace(/\.\[/g, '[') - + const issuePath = issue.path ?? [] + + let currentFormValue = formValue + let path = '' + + for (let i = 0; i < issuePath.length; i++) { + const pathSegment = issuePath[i] + if (pathSegment === undefined) continue + + const segment = + typeof pathSegment === 'object' ? pathSegment.key : pathSegment + + // Standard Schema doesn't specify if paths should use numbers or stringified numbers for array access. + // However, if we follow the path it provides and encounter an array, then we can assume it's intended for array access. + const segmentAsNumber = Number(segment) + if (Array.isArray(currentFormValue) && !Number.isNaN(segmentAsNumber)) { + path += `[${segmentAsNumber}]` + } else { + path += (i > 0 ? '.' : '') + String(segment) + } + + if (typeof currentFormValue === 'object' && currentFormValue !== null) { + currentFormValue = currentFormValue[segment as never] + } else { + currentFormValue = undefined + } + } schema.set(path, (schema.get(path) ?? []).concat(issue)) } @@ -42,8 +61,9 @@ function prefixSchemaToErrors(issues: readonly StandardSchemaV1Issue[]) { const transformFormIssues = ( issues: readonly StandardSchemaV1Issue[], + formValue: unknown, ): TStandardSchemaValidatorIssue => { - const schemaErrors = prefixSchemaToErrors(issues) + const schemaErrors = prefixSchemaToErrors(issues, formValue) return { form: schemaErrors, fields: schemaErrors, @@ -68,7 +88,7 @@ export const standardSchemaValidators = { if (validationSource === 'field') return result.issues as TStandardSchemaValidatorIssue - return transformFormIssues(result.issues) + return transformFormIssues(result.issues, value) }, async validateAsync( { @@ -83,7 +103,7 @@ export const standardSchemaValidators = { if (validationSource === 'field') return result.issues as TStandardSchemaValidatorIssue - return transformFormIssues(result.issues) + return transformFormIssues(result.issues, value) }, } diff --git a/packages/form-core/tests/standardSchemaValidator.spec.ts b/packages/form-core/tests/standardSchemaValidator.spec.ts index 2a03c1968..ca0a7c78f 100644 --- a/packages/form-core/tests/standardSchemaValidator.spec.ts +++ b/packages/form-core/tests/standardSchemaValidator.spec.ts @@ -398,4 +398,189 @@ describe('standard schema validator', () => { it.todo( 'Should allow for `disableErrorFlat` to disable flattening `errors` array', ) + + describe('array path handling', () => { + it('should handle numeric array indices correctly', async () => { + const form = new FormApi({ + defaultValues: { + people: [{ name: '' }], + }, + validators: { + onChange: z.object({ + people: z.array( + z.object({ + name: z.string().min(1, 'Name is required'), + }), + ), + }), + }, + }) + + const field = new FieldApi({ + form, + name: 'people[0].name', + }) + + field.mount() + + field.setValue('') + expect(form.state.errors).toMatchObject([ + { + 'people[0].name': [{ message: 'Name is required' }], + }, + ]) + }) + + it('should handle string array indices from standard schema validators', async () => { + // Use Zod's superRefine to simulate string paths that some standard schema validators return + const schemaWithStringPaths = z + .object({ + people: z.array( + z.object({ + name: z.string(), + }), + ), + }) + .superRefine((_, ctx) => { + ctx.addIssue({ + code: 'custom', + message: 'Name is required', + path: ['people', '0', 'name'], // String index to test path handling + }) + }) + + const form = new FormApi({ + defaultValues: { + people: [{ name: '' }], + }, + validators: { + onChange: schemaWithStringPaths, + }, + }) + + const field = new FieldApi({ + form, + name: 'people[0].name', + }) + + field.mount() + + field.setValue('') + expect(form.state.errors).toMatchObject([ + { + 'people[0].name': [{ message: 'Name is required' }], + }, + ]) + }) + + it('should handle nested arrays with mixed numeric and string indices', async () => { + const form = new FormApi({ + defaultValues: { + users: [ + { + addresses: [ + { street: 'Main St' }, + { street: '' }, // This will fail validation + ], + }, + ], + }, + validators: { + onChange: z.object({ + users: z.array( + z.object({ + addresses: z.array( + z.object({ + street: z.string().min(1, 'Street is required'), + }), + ), + }), + ), + }), + }, + }) + + const field = new FieldApi({ + form, + name: 'users[0].addresses[1].street', + }) + + field.mount() + field.setValue('') + + expect(form.state.errors).toMatchObject([ + { + 'users[0].addresses[1].street': [{ message: 'Street is required' }], + }, + ]) + }) + + it('should handle regular object paths without array indices', async () => { + const form = new FormApi({ + defaultValues: { + user: { + profile: { + name: '', + }, + }, + }, + validators: { + onChange: z.object({ + user: z.object({ + profile: z.object({ + name: z.string().min(1, 'Name is required'), + }), + }), + }), + }, + }) + + const field = new FieldApi({ + form, + name: 'user.profile.name', + }) + + field.mount() + + field.setValue('') + expect(form.state.errors).toMatchObject([ + { + 'user.profile.name': [{ message: 'Name is required' }], + }, + ]) + }) + + it('should allow numeric object properties for standard schema issue paths', () => { + const form = new FormApi({ + defaultValues: { + foo: { + 0: { bar: '' }, + }, + }, + validators: { + onChange: z.object({ + foo: z.object({ + 0: z.object({ bar: z.string().email('Must be an email') }), + }), + }), + }, + }) + form.mount() + + const field = new FieldApi({ + form, + name: 'foo.0.bar', + }) + field.mount() + + field.setValue('test') + + expect(form.state.errors).toMatchObject([ + { 'foo.0.bar': [{ message: 'Must be an email' }] }, + ]) + expect(field.state.meta.errors).toMatchObject([ + { message: 'Must be an email' }, + ]) + }) + }) })