Skip to content

Commit ec8ce81

Browse files
erikras-gilfoyle-agentErik Rasmussenerikras-dinesh-agent
authored
Fix #869: Synchronize field name and value when name changes dynamically (#1078)
* Fix #869: Synchronize field name and value when name changes dynamically * Fix lint: Convert test to .js format to match project conventions * Update test comments to reflect that bug is now fixed * Add assertions for render spy calls per CodeRabbit feedback * Tighten test assertions: after rerender only expect field 'b' * Add checkbox and radio tests for dynamic name changes * Docs: add file-level comment clarifying test coverage scope --------- Co-authored-by: Erik Rasmussen <erik@mini.local> Co-authored-by: erikras-dinesh-agent <dinesh@openclaw.dev>
1 parent e82df67 commit ec8ce81

2 files changed

Lines changed: 276 additions & 2 deletions

File tree

Lines changed: 267 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,267 @@
1+
/**
2+
* @jest-environment jsdom
3+
*/
4+
// Tests for dynamic Field name changes (issue #869).
5+
// Covers text inputs, checkboxes, and radio buttons.
6+
import React from 'react'
7+
import { render, cleanup, act } from '@testing-library/react'
8+
import '@testing-library/jest-dom'
9+
import Form from './ReactFinalForm'
10+
import Field from './Field'
11+
12+
describe('useField - Dynamic Name (Issue #869)', () => {
13+
afterEach(cleanup)
14+
15+
it('should keep name and value in sync when field name changes', () => {
16+
const renderSpy = jest.fn()
17+
18+
const TestComponent = ({ fieldName }) => {
19+
return (
20+
<Form
21+
onSubmit={() => {}}
22+
initialValues={{ a: 'value-a', b: 'value-b' }}
23+
>
24+
{() => (
25+
<Field name={fieldName}>
26+
{({ input }) => {
27+
// Log every render to track name/value sync
28+
renderSpy(input.name, input.value)
29+
return <input {...input} data-testid="field" />
30+
}}
31+
</Field>
32+
)}
33+
</Form>
34+
)
35+
}
36+
37+
const { rerender } = render(<TestComponent fieldName="a" />)
38+
39+
// Initial render - field 'a'
40+
expect(renderSpy).toHaveBeenCalledWith('a', 'value-a')
41+
42+
renderSpy.mockClear()
43+
44+
// Change field name from 'a' to 'b'
45+
act(() => {
46+
rerender(<TestComponent fieldName="b" />)
47+
})
48+
49+
// Verify all renders after name change have name='b' and value='value-b'
50+
const calls = renderSpy.mock.calls
51+
52+
// Ensure Field actually rendered
53+
expect(calls.length).toBeGreaterThan(0)
54+
55+
// After rerender with fieldName="b", ALL calls should be for field 'b'
56+
calls.forEach(call => {
57+
const [name, value] = call
58+
expect(name).toBe('b')
59+
expect(value).toBe('value-b')
60+
})
61+
})
62+
63+
it('should have correct value immediately after name change (no stale renders)', () => {
64+
const TestComponent = ({ fieldName }) => {
65+
return (
66+
<Form
67+
onSubmit={() => {}}
68+
initialValues={{ a: 'value-a', b: 'value-b' }}
69+
>
70+
{() => (
71+
<Field name={fieldName}>
72+
{({ input }) => (
73+
<div>
74+
<span data-testid="name">{input.name}</span>
75+
<span data-testid="value">{input.value}</span>
76+
</div>
77+
)}
78+
</Field>
79+
)}
80+
</Form>
81+
)
82+
}
83+
84+
const { rerender, getByTestId } = render(<TestComponent fieldName="a" />)
85+
86+
expect(getByTestId('name')).toHaveTextContent('a')
87+
expect(getByTestId('value')).toHaveTextContent('value-a')
88+
89+
// Change field name
90+
act(() => {
91+
rerender(<TestComponent fieldName="b" />)
92+
})
93+
94+
// Immediately after rerender, name and value should be in sync
95+
expect(getByTestId('name')).toHaveTextContent('b')
96+
expect(getByTestId('value')).toHaveTextContent('value-b')
97+
})
98+
99+
it('should keep name and checked in sync when checkbox field name changes', () => {
100+
const renderSpy = jest.fn()
101+
102+
const TestComponent = ({ fieldName }) => {
103+
return (
104+
<Form
105+
onSubmit={() => {}}
106+
initialValues={{ a: true, b: false }}
107+
>
108+
{() => (
109+
<Field name={fieldName} type="checkbox">
110+
{({ input }) => {
111+
// Log every render to track name/checked sync
112+
renderSpy(input.name, input.checked)
113+
return <input {...input} data-testid="field" />
114+
}}
115+
</Field>
116+
)}
117+
</Form>
118+
)
119+
}
120+
121+
const { rerender } = render(<TestComponent fieldName="a" />)
122+
123+
// Initial render - field 'a' checked
124+
expect(renderSpy).toHaveBeenCalledWith('a', true)
125+
126+
renderSpy.mockClear()
127+
128+
// Change field name from 'a' to 'b'
129+
act(() => {
130+
rerender(<TestComponent fieldName="b" />)
131+
})
132+
133+
// Verify all renders after name change have name='b' and checked=false
134+
const calls = renderSpy.mock.calls
135+
136+
// Ensure Field actually rendered
137+
expect(calls.length).toBeGreaterThan(0)
138+
139+
// After rerender with fieldName="b", ALL calls should be for field 'b'
140+
calls.forEach(call => {
141+
const [name, checked] = call
142+
expect(name).toBe('b')
143+
expect(checked).toBe(false)
144+
})
145+
})
146+
147+
it('should have correct checked immediately after checkbox name change', () => {
148+
const TestComponent = ({ fieldName }) => {
149+
return (
150+
<Form
151+
onSubmit={() => {}}
152+
initialValues={{ a: true, b: false }}
153+
>
154+
{() => (
155+
<Field name={fieldName} type="checkbox">
156+
{({ input }) => (
157+
<div>
158+
<span data-testid="name">{input.name}</span>
159+
<span data-testid="checked">{String(input.checked)}</span>
160+
</div>
161+
)}
162+
</Field>
163+
)}
164+
</Form>
165+
)
166+
}
167+
168+
const { rerender, getByTestId } = render(<TestComponent fieldName="a" />)
169+
170+
expect(getByTestId('name')).toHaveTextContent('a')
171+
expect(getByTestId('checked')).toHaveTextContent('true')
172+
173+
// Change field name
174+
act(() => {
175+
rerender(<TestComponent fieldName="b" />)
176+
})
177+
178+
// Immediately after rerender, name and checked should be in sync
179+
expect(getByTestId('name')).toHaveTextContent('b')
180+
expect(getByTestId('checked')).toHaveTextContent('false')
181+
})
182+
183+
it('should keep name and checked in sync when radio field name changes', () => {
184+
const renderSpy = jest.fn()
185+
186+
const TestComponent = ({ fieldName }) => {
187+
return (
188+
<Form
189+
onSubmit={() => {}}
190+
initialValues={{ a: 'option1', b: 'option2' }}
191+
>
192+
{() => (
193+
<Field name={fieldName} type="radio" value="option2">
194+
{({ input }) => {
195+
// Log every render to track name/checked sync
196+
renderSpy(input.name, input.checked)
197+
return <input {...input} data-testid="field" />
198+
}}
199+
</Field>
200+
)}
201+
</Form>
202+
)
203+
}
204+
205+
const { rerender } = render(<TestComponent fieldName="a" />)
206+
207+
// Initial render - field 'a' has value 'option1', not checked for 'option2'
208+
expect(renderSpy).toHaveBeenCalledWith('a', false)
209+
210+
renderSpy.mockClear()
211+
212+
// Change field name from 'a' to 'b'
213+
act(() => {
214+
rerender(<TestComponent fieldName="b" />)
215+
})
216+
217+
// Verify all renders after name change have name='b' and checked=true
218+
const calls = renderSpy.mock.calls
219+
220+
// Ensure Field actually rendered
221+
expect(calls.length).toBeGreaterThan(0)
222+
223+
// After rerender with fieldName="b", ALL calls should be for field 'b'
224+
// Field 'b' has value 'option2', so radio with value="option2" should be checked
225+
calls.forEach(call => {
226+
const [name, checked] = call
227+
expect(name).toBe('b')
228+
expect(checked).toBe(true)
229+
})
230+
})
231+
232+
it('should have correct checked immediately after radio name change', () => {
233+
const TestComponent = ({ fieldName }) => {
234+
return (
235+
<Form
236+
onSubmit={() => {}}
237+
initialValues={{ a: 'option1', b: 'option2' }}
238+
>
239+
{() => (
240+
<Field name={fieldName} type="radio" value="option2">
241+
{({ input }) => (
242+
<div>
243+
<span data-testid="name">{input.name}</span>
244+
<span data-testid="checked">{String(input.checked)}</span>
245+
</div>
246+
)}
247+
</Field>
248+
)}
249+
</Form>
250+
)
251+
}
252+
253+
const { rerender, getByTestId } = render(<TestComponent fieldName="a" />)
254+
255+
expect(getByTestId('name')).toHaveTextContent('a')
256+
expect(getByTestId('checked')).toHaveTextContent('false')
257+
258+
// Change field name
259+
act(() => {
260+
rerender(<TestComponent fieldName="b" />)
261+
})
262+
263+
// Immediately after rerender, name and checked should be in sync
264+
expect(getByTestId('name')).toHaveTextContent('b')
265+
expect(getByTestId('checked')).toHaveTextContent('true')
266+
})
267+
})

src/useField.ts

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -187,7 +187,11 @@ function useField<
187187
const meta: any = {};
188188
addLazyFieldMetaState(meta, state);
189189
const getInputValue = () => {
190-
let value = state.value;
190+
// Fix #869: If name changed but state hasn't updated yet (effect hasn't run),
191+
// get the value directly from form values to avoid returning stale value
192+
let value = state.name !== name
193+
? getIn(form.getState().values, name)
194+
: state.value;
191195

192196
// Handle null values first
193197
if (value === null && !allowNull) {
@@ -221,7 +225,10 @@ function useField<
221225
};
222226

223227
const getInputChecked = () => {
224-
let value = state.value;
228+
// Fix #869: Same as getInputValue - sync with current name
229+
let value = state.name !== name
230+
? getIn(form.getState().values, name)
231+
: state.value;
225232
if (type === "checkbox") {
226233
value = parse(value, name);
227234
if (_value === undefined) {

0 commit comments

Comments
 (0)