From e409b42f60dcf40520c9b7202da2228e699a98f4 Mon Sep 17 00:00:00 2001 From: Scott Cooper Date: Sun, 23 Aug 2026 23:09:29 -0700 Subject: [PATCH] fix(react-form): Keep ArrayField in sync after replacement Replacing an array with another array of the same length updated form state without rerendering ArrayField. Bump the shared array version for direct replacements while keeping nested field updates isolated. Co-Authored-By: OpenAI Codex --- packages/form-core/src/FormApi/FormApi.lib.ts | 17 +++++- .../src/FormApi/array-methods.lib.ts | 16 ------ .../tests/FormApi/field-state.spec.ts | 53 +++++++++++++++++++ packages/react-form/tests/FormField.spec.tsx | 47 ++++++++++++++++ 4 files changed, 116 insertions(+), 17 deletions(-) diff --git a/packages/form-core/src/FormApi/FormApi.lib.ts b/packages/form-core/src/FormApi/FormApi.lib.ts index 8a85757888..08c6681b2f 100644 --- a/packages/form-core/src/FormApi/FormApi.lib.ts +++ b/packages/form-core/src/FormApi/FormApi.lib.ts @@ -502,7 +502,22 @@ export class InternalFormApi< updateOptions.fieldApiOverride = field batch(() => { - this._atoms.values.set((prev) => setBy(prev, fieldName, updater)) + const previousValue = this.getFieldValue(fieldName) + const nextValue = callUpdater(updater, previousValue) + const replacedSameLengthArray = + Array.isArray(previousValue) && + Array.isArray(nextValue) && + previousValue !== nextValue && + previousValue.length === nextValue.length + + this._atoms.values.set((prev) => setBy(prev, fieldName, nextValue)) + + if (field && replacedSameLengthArray) { + field._setMeta((prev) => ({ + ...prev, + _arrayVersion: prev._arrayVersion + 1, + })) + } this._notifyFieldChange(field, updateOptions) }) diff --git a/packages/form-core/src/FormApi/array-methods.lib.ts b/packages/form-core/src/FormApi/array-methods.lib.ts index ea568f8c2e..55c86da77a 100644 --- a/packages/form-core/src/FormApi/array-methods.lib.ts +++ b/packages/form-core/src/FormApi/array-methods.lib.ts @@ -203,12 +203,6 @@ function swapFieldValues({ if (!arrayField) return - // Since the length wasn't changed, we need to notify manually - arrayField._setMeta((prev) => ({ - ...prev, - _arrayVersion: prev._arrayVersion + 1, - })) - const fieldA = tryGetFieldApi(arrayField, [indexA]) const fieldB = tryGetFieldApi(arrayField, [indexB]) @@ -266,11 +260,6 @@ function moveFieldValue({ if (!arrayField) return - arrayField._setMeta((prev) => ({ - ...prev, - _arrayVersion: prev._arrayVersion + 1, - })) - const movingChild = tryGetFieldApi(arrayField, [fromIndex]) for (const child of arrayField._children) { @@ -312,11 +301,6 @@ function clearFieldValues({ if (!arrayField) return - arrayField._setMeta((prev) => ({ - ...prev, - _arrayVersion: prev._arrayVersion + 1, - })) - // Kill all child fields since the array is now empty // _kill() will remove each child from the parent's children for (const child of arrayField._children) { diff --git a/packages/form-core/tests/FormApi/field-state.spec.ts b/packages/form-core/tests/FormApi/field-state.spec.ts index 3570952ee6..970c6e65c7 100644 --- a/packages/form-core/tests/FormApi/field-state.spec.ts +++ b/packages/form-core/tests/FormApi/field-state.spec.ts @@ -55,6 +55,59 @@ describe('form - field state', () => { expect(form.getFieldValue('count')).toBe(2) }) + it('increments the array version for a same-length array replacement', () => { + const form = new InternalFormApi({ defaultValues: { items: ['a'] } }) + const field = form._getOrCreateFieldApi({ name: 'items' }) + + form.setFieldValue('items', ['b']) + + expect(form.getFieldValue('items')).toEqual(['b']) + expect(field.meta._arrayVersion).toBe(1) + }) + + it('increments the array version for an updater replacement', () => { + const form = new InternalFormApi({ defaultValues: { items: ['a'] } }) + const field = form._getOrCreateFieldApi({ name: 'items' }) + + form.setFieldValue('items', (items: Array) => + items.map((item) => item.toUpperCase()), + ) + + expect(form.getFieldValue('items')).toEqual(['A']) + expect(field.meta._arrayVersion).toBe(1) + }) + + it('does not increment a parent array version for a nested field update', () => { + const form = new InternalFormApi({ + defaultValues: { items: [{ label: 'a' }] }, + }) + const field = form._getOrCreateFieldApi({ name: 'items' }) + + form.setFieldValue('items[0].label', 'b') + + expect(form.getFieldValue('items')).toEqual([{ label: 'b' }]) + expect(field.meta._arrayVersion).toBe(0) + }) + + it('increments the array version only once for array helpers', () => { + const form = new InternalFormApi({ + defaultValues: { items: ['a', 'b', 'c'] }, + }) + const field = form._getOrCreateFieldApi({ name: 'items' }) + + form.swapFieldValues('items', 0, 1) + expect(field.meta._arrayVersion).toBe(1) + + form.moveFieldValue('items', 0, 2) + expect(field.meta._arrayVersion).toBe(2) + + form.clearFieldValues('items') + expect(field.meta._arrayVersion).toBe(2) + + form.clearFieldValues('items') + expect(field.meta._arrayVersion).toBe(3) + }) + it('marks form isTouched and isDirty after a change', () => { const form = new InternalFormApi({ defaultValues: { name: '' } }) const field = form._getOrCreateFieldApi({ name: 'name' }) diff --git a/packages/react-form/tests/FormField.spec.tsx b/packages/react-form/tests/FormField.spec.tsx index badfe76f7c..180d05c053 100644 --- a/packages/react-form/tests/FormField.spec.tsx +++ b/packages/react-form/tests/FormField.spec.tsx @@ -101,6 +101,53 @@ describe('Form fields', () => { expect(input).toHaveValue('new-value') }) + it('rerenders an ArrayField for a same-length replacement', () => { + let renders = 0 + + function Component() { + const form = useForm({ + defaultValues: { items: [{ label: 'A' }] }, + }) + + return ( + <> +