diff --git a/.changeset/fix-array-removevalue-isvalidating-stuck.md b/.changeset/fix-array-removevalue-isvalidating-stuck.md new file mode 100644 index 000000000..98f426434 --- /dev/null +++ b/.changeset/fix-array-removevalue-isvalidating-stuck.md @@ -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 diff --git a/packages/form-core/src/FieldApi.ts b/packages/form-core/src/FieldApi.ts index 80770f889..68052d839 100644 --- a/packages/form-core/src/FieldApi.ts +++ b/packages/form-core/src/FieldApi.ts @@ -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 { diff --git a/packages/form-core/src/metaHelper.ts b/packages/form-core/src/metaHelper.ts index bab8a0630..c13924829 100644 --- a/packages/form-core/src/metaHelper.ts +++ b/packages/form-core/src/metaHelper.ts @@ -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()) } diff --git a/packages/form-core/tests/FieldApi.spec.ts b/packages/form-core/tests/FieldApi.spec.ts index 9c3ed43e4..3af6f519f 100644 --- a/packages/form-core/tests/FieldApi.spec.ts +++ b/packages/form-core/tests/FieldApi.spec.ts @@ -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((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: {