Skip to content

Commit 6e5b809

Browse files
scttcpercodexautofix-ci[bot]LeCarbonator
authored
[v2] Fix: Keep ArrayField in sync after array replacement (#2356)
* 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 <noreply@openai.com> * ci: apply automated fixes and generate docs * Update packages/form-core/src/FormApi/FormApi.lib.ts --------- Co-authored-by: OpenAI Codex <noreply@openai.com> Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com> Co-authored-by: LeCarbonator <18158911+LeCarbonator@users.noreply.github.com>
1 parent 26617df commit 6e5b809

4 files changed

Lines changed: 114 additions & 17 deletions

File tree

packages/form-core/src/FormApi/FormApi.lib.ts

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -507,7 +507,22 @@ export class InternalFormApi<
507507
updateOptions.fieldApiOverride = field
508508

509509
batch(() => {
510-
this._atoms.values.set((prev) => setBy(prev, fieldName, updater))
510+
const previousValue = this.getFieldValue(fieldName)
511+
const nextValue = callUpdater(updater, previousValue)
512+
const replacedSameLengthArray =
513+
Array.isArray(previousValue) &&
514+
Array.isArray(nextValue) &&
515+
previousValue !== nextValue &&
516+
previousValue.length === nextValue.length
517+
518+
this._atoms.values.set((prev) => setBy(prev, fieldName, () => nextValue))
519+
520+
if (field && replacedSameLengthArray) {
521+
field._setMeta((prev) => ({
522+
...prev,
523+
_arrayVersion: prev._arrayVersion + 1,
524+
}))
525+
}
511526

512527
this._notifyFieldChange(field, updateOptions)
513528
})

packages/form-core/src/FormApi/array-methods.lib.ts

Lines changed: 0 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -203,12 +203,6 @@ function swapFieldValues({
203203

204204
if (!arrayField) return
205205

206-
// Since the length wasn't changed, we need to notify manually
207-
arrayField._setMeta((prev) => ({
208-
...prev,
209-
_arrayVersion: prev._arrayVersion + 1,
210-
}))
211-
212206
const fieldA = tryGetFieldApi(arrayField, [indexA])
213207

214208
const fieldB = tryGetFieldApi(arrayField, [indexB])
@@ -266,11 +260,6 @@ function moveFieldValue({
266260

267261
if (!arrayField) return
268262

269-
arrayField._setMeta((prev) => ({
270-
...prev,
271-
_arrayVersion: prev._arrayVersion + 1,
272-
}))
273-
274263
const movingChild = tryGetFieldApi(arrayField, [fromIndex])
275264

276265
for (const child of arrayField._children) {
@@ -312,11 +301,6 @@ function clearFieldValues({
312301

313302
if (!arrayField) return
314303

315-
arrayField._setMeta((prev) => ({
316-
...prev,
317-
_arrayVersion: prev._arrayVersion + 1,
318-
}))
319-
320304
// Kill all child fields since the array is now empty
321305
// _kill() will remove each child from the parent's children
322306
for (const child of arrayField._children) {

packages/form-core/tests/FormApi/field-state.spec.ts

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,59 @@ describe('form - field state', () => {
5555
expect(form.getFieldValue('count')).toBe(2)
5656
})
5757

58+
it('increments the array version for a same-length array replacement', () => {
59+
const form = new InternalFormApi({ defaultValues: { items: ['a'] } })
60+
const field = form._getOrCreateFieldApi({ name: 'items' })
61+
62+
form.setFieldValue('items', ['b'])
63+
64+
expect(form.getFieldValue('items')).toEqual(['b'])
65+
expect(field.meta._arrayVersion).toBe(1)
66+
})
67+
68+
it('increments the array version for an updater replacement', () => {
69+
const form = new InternalFormApi({ defaultValues: { items: ['a'] } })
70+
const field = form._getOrCreateFieldApi({ name: 'items' })
71+
72+
form.setFieldValue('items', (items: Array<string>) =>
73+
items.map((item) => item.toUpperCase()),
74+
)
75+
76+
expect(form.getFieldValue('items')).toEqual(['A'])
77+
expect(field.meta._arrayVersion).toBe(1)
78+
})
79+
80+
it('does not increment a parent array version for a nested field update', () => {
81+
const form = new InternalFormApi({
82+
defaultValues: { items: [{ label: 'a' }] },
83+
})
84+
const field = form._getOrCreateFieldApi({ name: 'items' })
85+
86+
form.setFieldValue('items[0].label', 'b')
87+
88+
expect(form.getFieldValue('items')).toEqual([{ label: 'b' }])
89+
expect(field.meta._arrayVersion).toBe(0)
90+
})
91+
92+
it('increments the array version only once for array helpers', () => {
93+
const form = new InternalFormApi({
94+
defaultValues: { items: ['a', 'b', 'c'] },
95+
})
96+
const field = form._getOrCreateFieldApi({ name: 'items' })
97+
98+
form.swapFieldValues('items', 0, 1)
99+
expect(field.meta._arrayVersion).toBe(1)
100+
101+
form.moveFieldValue('items', 0, 2)
102+
expect(field.meta._arrayVersion).toBe(2)
103+
104+
form.clearFieldValues('items')
105+
expect(field.meta._arrayVersion).toBe(2)
106+
107+
form.clearFieldValues('items')
108+
expect(field.meta._arrayVersion).toBe(3)
109+
})
110+
58111
it('marks form isTouched and isDirty after a change', () => {
59112
const form = new InternalFormApi({ defaultValues: { name: '' } })
60113
const field = form._getOrCreateFieldApi({ name: 'name' })

packages/react-form/tests/FormField.spec.tsx

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -101,6 +101,51 @@ describe('Form fields', () => {
101101
expect(input).toHaveValue('new-value')
102102
})
103103

104+
it('rerenders an ArrayField for a same-length replacement', () => {
105+
let renders = 0
106+
107+
function Component() {
108+
const form = useForm({
109+
defaultValues: { items: [{ label: 'A' }] },
110+
})
111+
112+
return (
113+
<>
114+
<button
115+
data-testid="replace-array"
116+
onClick={() => form.setFieldValue('items', [{ label: 'B' }])}
117+
/>
118+
<button
119+
data-testid="update-child"
120+
onClick={() => form.setFieldValue('items[0].label', 'C')}
121+
/>
122+
<form.ArrayField name="items">
123+
{(field) => {
124+
renders++
125+
return (
126+
<output data-testid="array">{field.value[0]!.label}</output>
127+
)
128+
}}
129+
</form.ArrayField>
130+
</>
131+
)
132+
}
133+
134+
const { getByTestId } = render(<Component />)
135+
const initialRenders = renders
136+
137+
fireEvent.click(getByTestId('replace-array'))
138+
139+
expect(getByTestId('array')).toHaveTextContent('B')
140+
expect(renders).toBeGreaterThan(initialRenders)
141+
142+
const replacementRenders = renders
143+
144+
fireEvent.click(getByTestId('update-child'))
145+
146+
expect(renders).toBe(replacementRenders)
147+
})
148+
104149
it('should have the correct meta when changing the field', async () => {
105150
function Component() {
106151
const form = useForm({ defaultValues: { name: 'tony-hawk' } })

0 commit comments

Comments
 (0)