Skip to content
Merged
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
2 changes: 1 addition & 1 deletion packages/form-core/src/FieldApi/FieldApi.public.ts
Original file line number Diff line number Diff line change
Expand Up @@ -202,7 +202,7 @@ export interface FieldApiOptions<
name: TFieldName
errorVisibility?: ErrorVisibility<TFormData, TFormErrorTypes>
/**
* Route descendant field errors from form-level validation to this field.
* Route descendant field errors from form and form group validators to this field.
*/
errorBoundary?: boolean
validators?: TFieldValidators
Expand Down
64 changes: 40 additions & 24 deletions packages/form-core/src/FormApi/FormApi.lib.ts
Original file line number Diff line number Diff line change
Expand Up @@ -744,22 +744,47 @@ export class InternalFormApi<
)
}

_resolveErrorFieldPath(fieldName: string): string {
let current: AnyInternalFieldApi | InternalRootFieldApi =
this._fieldRootNode
let boundary: AnyInternalFieldApi | null = null
/**
* Resolve relative field errors from a validation scope to their concrete
* field targets, coalescing errors captured by the same boundary.
*/
_resolveRoutedFieldErrors(
fieldErrors: Iterable<readonly [string, Array<ValidationIssue>]>,
routingRoot: AnyInternalFieldApi | InternalRootFieldApi = this
._fieldRootNode,
): Map<AnyInternalFieldApi, Array<ValidationIssue>> {
const resolvedFieldErrors = new Map<
AnyInternalFieldApi,
Array<ValidationIssue>
>()

for (const [fieldName, errors] of fieldErrors) {
const segments = nameToFieldNodeSegments(fieldName)
let current = routingRoot
let boundary: AnyInternalFieldApi | null =
routingRoot._isRoot || !routingRoot._errorBoundary ? null : routingRoot

for (const segment of segments) {
const child: AnyInternalFieldApi | undefined =
current._getChild(segment)
if (!child) break

if (child._errorBoundary) {
boundary = child
}
current = child
}

for (const segment of nameToFieldNodeSegments(fieldName)) {
const child: AnyInternalFieldApi | undefined = current._getChild(segment)
if (!child) break
const target =
boundary ?? getOrCreateFieldApi(routingRoot, segments.slice(), this)

if (child._errorBoundary) {
boundary = child
}
current = child
resolvedFieldErrors.set(
target,
(resolvedFieldErrors.get(target) ?? []).concat(errors),
)
}

return boundary?.name ?? fieldName
return resolvedFieldErrors
}

_setFormValidatorError(
Expand Down Expand Up @@ -857,17 +882,9 @@ export class InternalFormApi<
}

const parsedResult = parseValidationResult(result.result)
const resolvedFieldErrors = new Map<string, Array<ValidationIssue>>()

for (const [fieldName, fieldErrors] of Object.entries(
parsedResult.subfields ?? {},
)) {
const resolvedName = this._resolveErrorFieldPath(fieldName)
resolvedFieldErrors.set(
resolvedName,
(resolvedFieldErrors.get(resolvedName) ?? []).concat(fieldErrors),
)
}
const resolvedFieldErrors = this._resolveRoutedFieldErrors(
Object.entries(parsedResult.subfields ?? {}),
)

batch(() => {
this._setFormValidatorError(
Expand All @@ -883,7 +900,6 @@ export class InternalFormApi<
result.validatorIndex,
resolvedFieldErrors,
oldFieldRefs,
(fieldName) => this._getOrCreateFieldApi({ name: fieldName }),
(field, index, errors) =>
this._setFieldValidatorError(field, index, errors, sourceEvent),
(field, index) => this._clearFieldValidatorError(field, index),
Expand Down
18 changes: 8 additions & 10 deletions packages/form-core/src/FormGroupApi/FormGroupApi.lib.ts
Original file line number Diff line number Diff line change
Expand Up @@ -340,27 +340,25 @@ export class InternalFormGroupApi<
const validatorIndex = result.validatorIndex
const groupField = this.form._getOrCreateFieldApi({ name: this.name })
const oldFieldRefs = this._fieldErrors[validatorIndex]
const resolvedFieldErrors = this.form._resolveRoutedFieldErrors(
Object.entries(parsedResult.subfields ?? {}),
groupField,
)
const groupFieldErrors = resolvedFieldErrors.get(groupField) ?? []
resolvedFieldErrors.delete(groupField)

batch(() => {
this._setFieldValidatorError(
groupField,
validatorIndex,
parsedResult.self ?? [],
(parsedResult.self ?? []).concat(groupFieldErrors),
sourceEvent,
)

const normalizedFieldErrors = Object.entries(
parsedResult.subfields ?? {},
).map(
([fieldName, fieldErrors]) =>
[this._getPrefixedFieldName(fieldName), fieldErrors] as const,
)

const { fieldRefs } = reconcileRoutedFieldErrors(
validatorIndex,
normalizedFieldErrors,
resolvedFieldErrors,
oldFieldRefs,
(fieldName) => this.form._getOrCreateFieldApi({ name: fieldName }),
(field, index, errors) =>
this._setFieldValidatorError(field, index, errors, sourceEvent),
(field, index) => this._clearFieldValidatorError(field, index),
Expand Down
6 changes: 2 additions & 4 deletions packages/form-core/src/validation.lib.ts
Original file line number Diff line number Diff line change
Expand Up @@ -345,9 +345,8 @@ export function clearIndexedErrorsFromSource(

export function reconcileRoutedFieldErrors(
validatorIndex: number,
fieldErrors: Iterable<readonly [string, Array<ValidationIssue>]>,
fieldErrors: Iterable<readonly [AnyInternalFieldApi, Array<ValidationIssue>]>,
oldFieldRefs: Set<AnyInternalFieldApi> | undefined,
getField: (fieldName: string) => AnyInternalFieldApi,
setFieldError: (
field: AnyInternalFieldApi,
validatorIndex: number,
Expand All @@ -363,8 +362,7 @@ export function reconcileRoutedFieldErrors(
const affectedFields = new Set<AnyInternalFieldApi>()
const newFieldRefs = new Set<AnyInternalFieldApi>()

for (const [fieldName, fieldError] of fieldErrors) {
const field = getField(fieldName)
for (const [field, fieldError] of fieldErrors) {
setFieldError(field, validatorIndex, fieldError)
newFieldRefs.add(field)
affectedFields.add(field)
Expand Down
137 changes: 137 additions & 0 deletions packages/form-core/tests/FormGroupApi/FormGroupApi.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -832,6 +832,143 @@ describe('FormGroupApi', () => {
expect(group.state.errors).toEqual([])
})

it('routes descendant Standard Schema issues to a field error boundary', async () => {
const form = new InternalFormApi({
defaultValues: {
stayDates: {
dateRange: { from: '', to: '' },
arrivalTime: '',
},
},
})
const dateRangeField = form._getOrCreateFieldApi({
name: 'stayDates.dateRange',
errorBoundary: true,
})
const arrivalTimeField = form._getOrCreateFieldApi({
name: 'stayDates.arrivalTime',
})
const group = new InternalFormGroupApi({
form,
name: 'stayDates',
validators: [
{
triggers: [],
run: z.object({
dateRange: z.object({
from: z.string().min(1, 'Start date is required'),
to: z.string().min(1, 'End date is required'),
}),
arrivalTime: z.string().min(1, 'Arrival time is required'),
}),
},
],
})

await group.validate('submit')

expect(dateRangeField.errors).toEqual([
expect.objectContaining({ message: 'Start date is required' }),
expect.objectContaining({ message: 'End date is required' }),
])
expect(arrivalTimeField.errors).toEqual([
expect.objectContaining({ message: 'Arrival time is required' }),
])
expect(form._tryGetFieldApi('stayDates.dateRange.from')).toBeNull()
expect(form._tryGetFieldApi('stayDates.dateRange.to')).toBeNull()
expect(group.state.isInvalid).toBe(true)

dateRangeField.handleChange(
{ from: '2026-08-10', to: '2026-08-12' },
{ causeValidation: false },
)
arrivalTimeField.handleChange('15:00', { causeValidation: false })
await group.validate('submit')

expect(dateRangeField.errors).toEqual([])
expect(arrivalTimeField.errors).toEqual([])
expect(group.state.isInvalid).toBe(false)
})

it('combines group self and descendant errors at a group root error boundary', async () => {
const form = new InternalFormApi({
defaultValues: {
stayDates: {
dateRange: { from: '', to: '' },
},
},
})
const groupField = form._getOrCreateFieldApi({
name: 'stayDates',
errorBoundary: true,
})
const group = new InternalFormGroupApi({
form,
name: 'stayDates',
validators: [
{
triggers: [],
run: () => ({
form: 'Stay dates are invalid',
fields: {
'dateRange.from': 'Start date is required',
'dateRange.to': 'End date is required',
},
}),
},
],
})

await group.validate('submit')

expect(groupField.errors).toEqual([
{ message: 'Stay dates are invalid' },
{ message: 'Start date is required' },
{ message: 'End date is required' },
])
expect(group.state.errors).toEqual(groupField.errors)
expect(form._tryGetFieldApi('stayDates.dateRange.from')).toBeNull()
expect(form._tryGetFieldApi('stayDates.dateRange.to')).toBeNull()
})

it('does not route group errors to a boundary outside the group', async () => {
const form = new InternalFormApi({
defaultValues: {
booking: {
stayDates: {
dateRange: { to: '' },
},
},
},
})
const bookingField = form._getOrCreateFieldApi({
name: 'booking',
errorBoundary: true,
})
const group = new InternalFormGroupApi({
form,
name: 'booking.stayDates',
validators: [
{
triggers: [],
run: () => ({
fields: {
'dateRange.to': 'End date is required',
},
}),
},
],
})

await group.validate('submit')

expect(bookingField.errors).toEqual([])
expect(
form._tryGetFieldApi('booking.stayDates.dateRange.to')?.errors,
).toEqual([{ message: 'End date is required' }])
expect(group.state.isInvalid).toBe(true)
})

it('clears routed group field errors when validation later passes', async () => {
let shouldError = true
const form = new InternalFormApi({
Expand Down
20 changes: 17 additions & 3 deletions packages/form-core/tests/validation.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -214,12 +214,28 @@ describe('parseValidationResult', () => {
})

describe('reconcileRoutedFieldErrors', () => {
it('sets errors on already-resolved field refs', () => {
const field = { name: 'name' } as AnyInternalFieldApi
const errors = [{ message: 'Name is required' }]
const setFieldError = vi.fn()
const result = reconcileRoutedFieldErrors(
2,
[[field, errors]],
undefined,
setFieldError,
vi.fn(),
)

expect(setFieldError).toHaveBeenCalledWith(field, 2, errors)
expect(result.fieldRefs).toEqual(new Set([field]))
expect(result.affectedFields).toEqual(new Set([field]))
})

it('reports unchanged refs when no new or old field refs exist', () => {
const result = reconcileRoutedFieldErrors(
0,
[],
undefined,
(fieldName) => ({ name: fieldName }) as AnyInternalFieldApi,
vi.fn(),
vi.fn(),
)
Expand All @@ -234,7 +250,6 @@ describe('reconcileRoutedFieldErrors', () => {
0,
[],
new Set(),
(fieldName) => ({ name: fieldName }) as AnyInternalFieldApi,
vi.fn(),
vi.fn(),
)
Expand All @@ -249,7 +264,6 @@ describe('reconcileRoutedFieldErrors', () => {
0,
[],
new Set([field]),
(fieldName) => ({ name: fieldName }) as AnyInternalFieldApi,
vi.fn(),
clearFieldError,
)
Expand Down
3 changes: 2 additions & 1 deletion packages/form-devtools/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,8 @@
"resize-observer-polyfill": "^1.5.1",
"solid-js": "^1.9.13",
"vite": "^8.1.2",
"vite-plugin-solid": "^2.11.10"
"vite-plugin-solid": "^2.11.10",
"zod": "^4.4.3"
},
"peerDependencies": {
"solid-js": ">=1.9.5"
Expand Down
9 changes: 2 additions & 7 deletions packages/form-devtools/tests/fieldDebugCases.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import {
InternalFormGroupApi,
} from '@tanstack/form-core/internals'
import { describe, expect, it } from 'vitest'
import { z } from 'zod'
import { getFieldDebugSuspicions } from '../src/bridge/fields/fieldDebug'
import type { AnyInternalFieldApi } from '@tanstack/form-core/internals'
import type { FieldDebugCase } from '../src/bridge/fields/fieldDebug'
Expand All @@ -19,13 +20,7 @@ const emptyTriggerValidator = {

const schemaValidator = {
triggers: ['change'] as const,
run: {
'~standard': {
version: 1,
vendor: 'field-debug-test',
validate: () => ({ value: undefined }),
},
},
run: z.unknown(),
}

function setFieldError(field: AnyInternalFieldApi) {
Expand Down
Loading
Loading