From 351d7521418373ad78dc6d9917f0b8d73e063db8 Mon Sep 17 00:00:00 2001 From: MarkXian Date: Thu, 16 Jul 2026 16:27:45 +0800 Subject: [PATCH] fix(form-core): reset stale validation counter when shifting array meta When an array item is removed (or inserted/moved) while an async validator is in flight, `shiftMeta` copied the source field's transient `_pendingValidationsCount`/`isValidating` down to the shifted index. The pending promise is owned by the original field instance and settles its counter against the original index, so the shifted index's counter is orphaned and never decremented, leaving `isFieldsValidating` stuck `true`. Reset the transient validation state when shifting meta, and guard `endValidation` so a validation that resolves after its field was removed no longer resurrects deleted meta. Closes #2234 --- ...ix-array-removevalue-isvalidating-stuck.md | 5 ++ packages/form-core/src/FieldApi.ts | 5 ++ packages/form-core/src/metaHelper.ts | 13 +++- packages/form-core/tests/FieldApi.spec.ts | 67 +++++++++++++++++++ 4 files changed, 89 insertions(+), 1 deletion(-) create mode 100644 .changeset/fix-array-removevalue-isvalidating-stuck.md 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: {