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
13 changes: 12 additions & 1 deletion packages/form-core/src/metaHelper.ts
Original file line number Diff line number Diff line change
Expand Up @@ -239,7 +239,18 @@ export function metaHelper<
const nextFieldKey = updateIndex(fieldKey.toString(), direction)
const nextFieldMeta = formApi.getFieldMeta(nextFieldKey)
if (nextFieldMeta) {
formApi.setFieldMeta(fieldKey, nextFieldMeta)
// 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 shifted 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
formApi.setFieldMeta(fieldKey, {
...nextFieldMeta,
isValidating: false,
_pendingValidationsCount: 0,
})
} else {
formApi.setFieldMeta(fieldKey, getEmptyFieldMeta())
}
Expand Down
67 changes: 67 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,73 @@ 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 swap a value from an array value correctly', () => {
const form = new FormApi({
defaultValues: {
Expand Down