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..c45fd6dc5 100644 --- a/packages/form-core/src/metaHelper.ts +++ b/packages/form-core/src/metaHelper.ts @@ -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) @@ -110,7 +112,10 @@ export function metaHelper< const fromMeta = fromFields.get(fromKey) if (fromMeta) { - formApi.setFieldMeta(fieldKey as DeepKeys, fromMeta) + formApi.setFieldMeta( + fieldKey as DeepKeys, + resetTransientValidationMeta(fromMeta), + ) } }) } @@ -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) => { @@ -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)) }) } @@ -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 { diff --git a/packages/form-core/tests/FieldApi.spec.ts b/packages/form-core/tests/FieldApi.spec.ts index 9c3ed43e4..5d05238e9 100644 --- a/packages/form-core/tests/FieldApi.spec.ts +++ b/packages/form-core/tests/FieldApi.spec.ts @@ -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((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: {