Skip to content

Commit 351d752

Browse files
committed
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
1 parent 5d11281 commit 351d752

4 files changed

Lines changed: 89 additions & 1 deletion

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@tanstack/form-core': patch
3+
---
4+
5+
Fix `isFieldsValidating` getting stuck `true` after `removeValue` on an array field while an async validation is in flight

packages/form-core/src/FieldApi.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1394,6 +1394,11 @@ export class FieldApi<
13941394
* Ends tracking an async validation, decrementing the counter and clearing isValidating if no validations remain.
13951395
*/
13961396
private endValidation() {
1397+
// The field may have been removed (e.g. via an array `removeValue`) while
1398+
// this async validation was still in flight. In that case its meta has
1399+
// already been deleted, so bail out instead of resurrecting empty meta.
1400+
// See https://github.com/TanStack/form/issues/2234
1401+
if (this.getInfo().instance !== this) return
13971402
this.setMeta((prev) => {
13981403
const newCount = Math.max(0, prev._pendingValidationsCount - 1)
13991404
return {

packages/form-core/src/metaHelper.ts

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -239,7 +239,18 @@ export function metaHelper<
239239
const nextFieldKey = updateIndex(fieldKey.toString(), direction)
240240
const nextFieldMeta = formApi.getFieldMeta(nextFieldKey)
241241
if (nextFieldMeta) {
242-
formApi.setFieldMeta(fieldKey, nextFieldMeta)
242+
// Transient async-validation state is bound to the specific field
243+
// instance that started the validation; that instance settles its own
244+
// counter against the original index once the promise resolves.
245+
// Carrying it over to a shifted index would orphan the counter and
246+
// leave `isValidating` stuck true. Array mutations re-trigger
247+
// validation on the shifted fields, so it is safe to reset here.
248+
// See https://github.com/TanStack/form/issues/2234
249+
formApi.setFieldMeta(fieldKey, {
250+
...nextFieldMeta,
251+
isValidating: false,
252+
_pendingValidationsCount: 0,
253+
})
243254
} else {
244255
formApi.setFieldMeta(fieldKey, getEmptyFieldMeta())
245256
}

packages/form-core/tests/FieldApi.spec.ts

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -652,6 +652,73 @@ describe('field api', () => {
652652
expect(form.state.canSubmit).toBe(true)
653653
})
654654

655+
it('should not leave isFieldsValidating stuck true when removing an array item while an async validation is in flight', async () => {
656+
vi.useFakeTimers()
657+
let resolve!: () => void
658+
let promise = new Promise<void>((r) => {
659+
resolve = r as never
660+
})
661+
662+
const form = new FormApi({
663+
defaultValues: {
664+
list: [
665+
{ operator: 'a', value: '1' },
666+
{ operator: 'b', value: '2' },
667+
],
668+
},
669+
})
670+
671+
const arrayField = new FieldApi({ form, name: 'list' })
672+
673+
const makeRow = (i: number) => {
674+
const operator = new FieldApi({
675+
form,
676+
name: `list[${i}].operator` as const,
677+
})
678+
const value = new FieldApi({
679+
form,
680+
name: `list[${i}].value` as const,
681+
validators: {
682+
onChangeListenTo: [`list[${i}].operator`],
683+
onChangeAsyncDebounceMs: 0,
684+
onChangeAsync: async () => {
685+
await promise
686+
return undefined
687+
},
688+
},
689+
})
690+
return { operator, value }
691+
}
692+
693+
form.mount()
694+
arrayField.mount()
695+
const row0 = makeRow(0)
696+
const row1 = makeRow(1)
697+
row0.operator.mount()
698+
row0.value.mount()
699+
row1.operator.mount()
700+
row1.value.mount()
701+
702+
// Kick off an async validation on row 1's value via its listened-to field
703+
row1.operator.setValue('changed')
704+
await Promise.resolve()
705+
expect(form.getFieldMeta('list[1].value')?.isValidating).toBe(true)
706+
707+
// Remove row 0 while row 1's async validation is still pending: row 1's
708+
// meta (with its in-flight validation counter) shifts down into row 0.
709+
await arrayField.removeValue(0)
710+
711+
// Resolve the pending validation and let everything settle
712+
resolve()
713+
await vi.runAllTimersAsync()
714+
715+
expect(form.getFieldMeta('list[0].value')?.isValidating).toBe(false)
716+
expect(form.getFieldMeta('list[0].value')?._pendingValidationsCount).toBe(0)
717+
expect(form.state.isFieldsValidating).toBe(false)
718+
719+
vi.useRealTimers()
720+
})
721+
655722
it('should swap a value from an array value correctly', () => {
656723
const form = new FormApi({
657724
defaultValues: {

0 commit comments

Comments
 (0)