Skip to content
Merged
5 changes: 5 additions & 0 deletions .changeset/poor-drinks-heal.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@tanstack/form-core': patch
---

fix(form-core): handle string array indices in prefixSchemaToErrors
50 changes: 35 additions & 15 deletions packages/form-core/src/standardSchemaValidator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, StandardSchemaV1Issue[]>()

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))
}

Expand All @@ -42,8 +61,9 @@ function prefixSchemaToErrors(issues: readonly StandardSchemaV1Issue[]) {

const transformFormIssues = <TSource extends ValidationSource>(
issues: readonly StandardSchemaV1Issue[],
formValue: unknown,
): TStandardSchemaValidatorIssue<TSource> => {
const schemaErrors = prefixSchemaToErrors(issues)
const schemaErrors = prefixSchemaToErrors(issues, formValue)
return {
form: schemaErrors,
fields: schemaErrors,
Expand All @@ -68,7 +88,7 @@ export const standardSchemaValidators = {

if (validationSource === 'field')
return result.issues as TStandardSchemaValidatorIssue<TSource>
return transformFormIssues<TSource>(result.issues)
return transformFormIssues<TSource>(result.issues, value)
},
async validateAsync<TSource extends ValidationSource>(
{
Expand All @@ -83,7 +103,7 @@ export const standardSchemaValidators = {

if (validationSource === 'field')
return result.issues as TStandardSchemaValidatorIssue<TSource>
return transformFormIssues<TSource>(result.issues)
return transformFormIssues<TSource>(result.issues, value)
},
}

Expand Down
185 changes: 185 additions & 0 deletions packages/form-core/tests/standardSchemaValidator.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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' },
])
})
})
})
Loading