Skip to content
Open
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
5 changes: 5 additions & 0 deletions .changeset/fix-array-removevalue-isvalidating-stuck.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@tanstack/form-core': patch
---

Fix `isFieldsValidating` getting stuck `true` after `removeValue` on an array field while an async validation is in flight
5 changes: 5 additions & 0 deletions packages/form-core/src/FieldApi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1394,6 +1394,11 @@ export class FieldApi<
* Ends tracking an async validation, decrementing the counter and clearing isValidating if no validations remain.
*/
private endValidation() {
// The field may have been removed (e.g. via an array `removeValue`) while
// this async validation was still in flight. In that case its meta has
// already been deleted, so bail out instead of resurrecting empty meta.
// See https://github.com/TanStack/form/issues/2234
if (this.getInfo().instance !== this) return
this.setMeta((prev) => {
const newCount = Math.max(0, prev._pendingValidationsCount - 1)
return {
Expand Down
37 changes: 33 additions & 4 deletions packages/form-core/src/metaHelper.ts
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,8 @@ export function metaHelper<
toIndex: number,
) {
bumpArrayVersion(field)
if (fromIndex === toIndex) return

const affectedFields = getAffectedFields(field, fromIndex, 'move', toIndex)

const startIndex = Math.min(fromIndex, toIndex)
Expand Down Expand Up @@ -110,7 +112,10 @@ export function metaHelper<

const fromMeta = fromFields.get(fromKey)
if (fromMeta) {
formApi.setFieldMeta(fieldKey as DeepKeys<TFormData>, fromMeta)
formApi.setFieldMeta(
fieldKey as DeepKeys<TFormData>,
resetTransientValidationMeta(fromMeta),
)
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}
})
}
Expand All @@ -134,6 +139,8 @@ export function metaHelper<
secondIndex: number,
) {
bumpArrayVersion(field)
if (index === secondIndex) return

const affectedFields = getAffectedFields(field, index, 'swap', secondIndex)

affectedFields.forEach((fieldKey) => {
Expand All @@ -153,8 +160,10 @@ export function metaHelper<
formApi.getFieldMeta(swappedKey),
]

if (meta1) formApi.setFieldMeta(swappedKey, meta1)
if (meta2) formApi.setFieldMeta(fieldKey, meta2)
if (meta1)
formApi.setFieldMeta(swappedKey, resetTransientValidationMeta(meta1))
if (meta2)
formApi.setFieldMeta(fieldKey, resetTransientValidationMeta(meta2))
})
}

Expand Down Expand Up @@ -239,13 +248,33 @@ export function metaHelper<
const nextFieldKey = updateIndex(fieldKey.toString(), direction)
const nextFieldMeta = formApi.getFieldMeta(nextFieldKey)
if (nextFieldMeta) {
formApi.setFieldMeta(fieldKey, nextFieldMeta)
formApi.setFieldMeta(
fieldKey,
resetTransientValidationMeta(nextFieldMeta),
)
} else {
formApi.setFieldMeta(fieldKey, getEmptyFieldMeta())
}
})
}

function resetTransientValidationMeta(
meta: AnyFieldLikeMeta,
): AnyFieldLikeMeta {
// Transient async-validation state is bound to the specific field instance
// that started the validation; that instance settles its own counter
// against the original index once the promise resolves. Carrying it over
// to a different array index would orphan the counter and leave
// `isValidating` stuck true. Array mutations re-trigger validation on the
// shifted fields, so it is safe to reset here.
// See https://github.com/TanStack/form/issues/2234
return {
...meta,
isValidating: false,
_pendingValidationsCount: 0,
}
}

const getEmptyFieldMeta = (): AnyFieldLikeMeta => defaultFieldMeta

return {
Expand Down
175 changes: 175 additions & 0 deletions packages/form-core/tests/FieldApi.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -652,6 +652,181 @@ describe('field api', () => {
expect(form.state.canSubmit).toBe(true)
})

it('should not leave isFieldsValidating stuck true when removing an array item while an async validation is in flight', async () => {
vi.useFakeTimers()
let resolve!: () => void
let promise = new Promise<void>((r) => {
resolve = r as never
})

const form = new FormApi({
defaultValues: {
list: [
{ operator: 'a', value: '1' },
{ operator: 'b', value: '2' },
],
},
})

const arrayField = new FieldApi({ form, name: 'list' })

const makeRow = (i: number) => {
const operator = new FieldApi({
form,
name: `list[${i}].operator` as const,
})
const value = new FieldApi({
form,
name: `list[${i}].value` as const,
validators: {
onChangeListenTo: [`list[${i}].operator`],
onChangeAsyncDebounceMs: 0,
onChangeAsync: async () => {
await promise
return undefined
},
},
})
return { operator, value }
}

form.mount()
arrayField.mount()
const row0 = makeRow(0)
const row1 = makeRow(1)
row0.operator.mount()
row0.value.mount()
row1.operator.mount()
row1.value.mount()

// Kick off an async validation on row 1's value via its listened-to field
row1.operator.setValue('changed')
await Promise.resolve()
expect(form.getFieldMeta('list[1].value')?.isValidating).toBe(true)

// Remove row 0 while row 1's async validation is still pending: row 1's
// meta (with its in-flight validation counter) shifts down into row 0.
await arrayField.removeValue(0)

// Resolve the pending validation and let everything settle
resolve()
await vi.runAllTimersAsync()

expect(form.getFieldMeta('list[0].value')?.isValidating).toBe(false)
expect(form.getFieldMeta('list[0].value')?._pendingValidationsCount).toBe(0)
expect(form.state.isFieldsValidating).toBe(false)

vi.useRealTimers()
})

it('should reset transient async validation state when moving array field meta', () => {
const form = new FormApi({
defaultValues: {
list: [{ value: 'a' }, { value: 'b' }],
},
})
const arrayField = new FieldApi({ form, name: 'list' })
const value0 = new FieldApi({ form, name: 'list[0].value' })
const value1 = new FieldApi({ form, name: 'list[1].value' })

form.mount()
arrayField.mount()
value0.mount()
value1.mount()

form.setFieldMeta('list[0].value', (meta) => ({
...meta,
isValidating: true,
_pendingValidationsCount: 1,
}))

arrayField.moveValue(0, 1, { dontValidate: true })

expect(form.getFieldMeta('list[1].value')?.isValidating).toBe(false)
expect(form.getFieldMeta('list[1].value')?._pendingValidationsCount).toBe(0)
expect(form.state.isFieldsValidating).toBe(false)
})

it('should preserve transient async validation state when moving array field meta to the same index', () => {
const form = new FormApi({
defaultValues: {
list: [{ value: 'a' }, { value: 'b' }],
},
})
const arrayField = new FieldApi({ form, name: 'list' })
const value0 = new FieldApi({ form, name: 'list[0].value' })

form.mount()
arrayField.mount()
value0.mount()

form.setFieldMeta('list[0].value', (meta) => ({
...meta,
isValidating: true,
_pendingValidationsCount: 1,
}))

arrayField.moveValue(0, 0, { dontValidate: true })

expect(form.getFieldMeta('list[0].value')?.isValidating).toBe(true)
expect(form.getFieldMeta('list[0].value')?._pendingValidationsCount).toBe(1)
expect(form.state.isFieldsValidating).toBe(true)
})

it('should reset transient async validation state when swapping array field meta', () => {
const form = new FormApi({
defaultValues: {
list: [{ value: 'a' }, { value: 'b' }],
},
})
const arrayField = new FieldApi({ form, name: 'list' })
const value0 = new FieldApi({ form, name: 'list[0].value' })
const value1 = new FieldApi({ form, name: 'list[1].value' })

form.mount()
arrayField.mount()
value0.mount()
value1.mount()

form.setFieldMeta('list[0].value', (meta) => ({
...meta,
isValidating: true,
_pendingValidationsCount: 1,
}))

arrayField.swapValues(0, 1, { dontValidate: true })

expect(form.getFieldMeta('list[1].value')?.isValidating).toBe(false)
expect(form.getFieldMeta('list[1].value')?._pendingValidationsCount).toBe(0)
expect(form.state.isFieldsValidating).toBe(false)
})

it('should preserve transient async validation state when swapping array field meta at the same index', () => {
const form = new FormApi({
defaultValues: {
list: [{ value: 'a' }, { value: 'b' }],
},
})
const arrayField = new FieldApi({ form, name: 'list' })
const value0 = new FieldApi({ form, name: 'list[0].value' })

form.mount()
arrayField.mount()
value0.mount()

form.setFieldMeta('list[0].value', (meta) => ({
...meta,
isValidating: true,
_pendingValidationsCount: 1,
}))

arrayField.swapValues(0, 0, { dontValidate: true })

expect(form.getFieldMeta('list[0].value')?.isValidating).toBe(true)
expect(form.getFieldMeta('list[0].value')?._pendingValidationsCount).toBe(1)
expect(form.state.isFieldsValidating).toBe(true)
})

it('should swap a value from an array value correctly', () => {
const form = new FormApi({
defaultValues: {
Expand Down