Skip to content

Commit e23f8b5

Browse files
Only restore the initial value when it was actually destroyed
useField's mount effect treated a missing FieldState as "the field was destroyed" and wrote initialValues[name] back. Final Form drops fields[name] on the last unregister either way, so that is also what a field mounting at a path written through form.change() looks like, and the write-back discarded live data. Restore only when the value was really destroyed, meaning destroyOnUnregister plus an empty path. The changed initialValue path gets its own explicit write-back instead of depending on that reset as a side effect. Fixes #1095
1 parent e09c3cf commit e23f8b5

2 files changed

Lines changed: 333 additions & 5 deletions

File tree

src/useField.issue-1095.test.js

Lines changed: 319 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,319 @@
1+
import * as React from "react";
2+
import { render, act } from "@testing-library/react";
3+
import "@testing-library/jest-dom";
4+
import Form from "./ReactFinalForm";
5+
import Field from "./Field";
6+
7+
const onSubmitMock = () => {};
8+
const arrayInitialValues = { items: [{ name: "a" }, { name: "b" }] };
9+
const nestedInitialValues = { parent: { child: { value: null } } };
10+
11+
describe("useField issue #1095", () => {
12+
it("does not overwrite a value set through change() when a field first mounts at that path", () => {
13+
let form;
14+
const { getByTestId, rerender } = render(
15+
<Form onSubmit={onSubmitMock} initialValues={{ foo: "A" }}>
16+
{(props) => {
17+
form = props.form;
18+
return null;
19+
}}
20+
</Form>,
21+
);
22+
23+
act(() => form.change("foo", "B"));
24+
25+
rerender(
26+
<Form onSubmit={onSubmitMock} initialValues={{ foo: "A" }}>
27+
{() => <Field name="foo" component="input" data-testid="foo" />}
28+
</Form>,
29+
);
30+
31+
expect(form.getState().values.foo).toBe("B");
32+
expect(getByTestId("foo").value).toBe("B");
33+
});
34+
35+
it("does not overwrite a nested value set through a parent field (wizard case)", () => {
36+
let form;
37+
const Step = ({ showLeaf }) =>
38+
showLeaf ? (
39+
<Field name="parent.child.value" component="input" data-testid="leaf" />
40+
) : (
41+
<Field name="parent" render={() => null} />
42+
);
43+
44+
const ui = (showLeaf) => (
45+
<Form onSubmit={onSubmitMock} initialValues={nestedInitialValues}>
46+
{(props) => {
47+
form = props.form;
48+
return <Step showLeaf={showLeaf} />;
49+
}}
50+
</Form>
51+
);
52+
53+
const { getByTestId, rerender } = render(ui(false));
54+
55+
act(() => form.change("parent.child.value", "chosen"));
56+
57+
rerender(ui(true));
58+
59+
expect(form.getState().values.parent.child.value).toBe("chosen");
60+
expect(getByTestId("leaf").value).toBe("chosen");
61+
});
62+
63+
it("preserves a changed value across unmount and remount of the field", () => {
64+
let form;
65+
const ui = (visible) => (
66+
<Form onSubmit={onSubmitMock} initialValues={{ foo: "A" }}>
67+
{(props) => {
68+
form = props.form;
69+
return visible ? (
70+
<Field name="foo" component="input" data-testid="foo" />
71+
) : null;
72+
}}
73+
</Form>
74+
);
75+
76+
const { getByTestId, rerender } = render(ui(true));
77+
78+
act(() => form.change("foo", "B"));
79+
rerender(ui(false));
80+
rerender(ui(true));
81+
82+
expect(form.getState().values.foo).toBe("B");
83+
expect(getByTestId("foo").value).toBe("B");
84+
});
85+
86+
it("still restores the initial value on remount when destroyOnUnregister is on (#1031)", () => {
87+
let form;
88+
const ui = (visible) => (
89+
<Form
90+
onSubmit={onSubmitMock}
91+
initialValues={{ foo: "A" }}
92+
destroyOnUnregister
93+
>
94+
{(props) => {
95+
form = props.form;
96+
return visible ? (
97+
<Field name="foo" component="input" data-testid="foo" />
98+
) : null;
99+
}}
100+
</Form>
101+
);
102+
103+
const { getByTestId, rerender } = render(ui(true));
104+
105+
act(() => form.change("foo", "B"));
106+
rerender(ui(false));
107+
108+
// destroyOnUnregister deleted the value on unregister
109+
expect(form.getState().values.foo).toBeUndefined();
110+
111+
rerender(ui(true));
112+
113+
expect(form.getState().values.foo).toBe("A");
114+
expect(getByTestId("foo").value).toBe("A");
115+
});
116+
117+
it("keeps initial values in StrictMode with destroyOnUnregister (#1031)", () => {
118+
const { getByTestId } = render(
119+
<React.StrictMode>
120+
<Form
121+
onSubmit={onSubmitMock}
122+
initialValues={{ foo: "A" }}
123+
destroyOnUnregister
124+
>
125+
{() => <Field name="foo" component="input" data-testid="foo" />}
126+
</Form>
127+
</React.StrictMode>,
128+
);
129+
130+
expect(getByTestId("foo").value).toBe("A");
131+
});
132+
133+
it("keeps a changed value in StrictMode without destroyOnUnregister", () => {
134+
let form;
135+
const ui = (visible) => (
136+
<React.StrictMode>
137+
<Form onSubmit={onSubmitMock} initialValues={{ foo: "A" }}>
138+
{(props) => {
139+
form = props.form;
140+
return visible ? (
141+
<Field name="foo" component="input" data-testid="foo" />
142+
) : null;
143+
}}
144+
</Form>
145+
</React.StrictMode>
146+
);
147+
148+
const { getByTestId, rerender } = render(ui(false));
149+
150+
act(() => form.change("foo", "B"));
151+
rerender(ui(true));
152+
153+
expect(form.getState().values.foo).toBe("B");
154+
expect(getByTestId("foo").value).toBe("B");
155+
});
156+
157+
it("does not restore the initial value after an intentional change to undefined", () => {
158+
let form;
159+
const ui = (visible) => (
160+
<Form onSubmit={onSubmitMock} initialValues={{ foo: "A" }}>
161+
{(props) => {
162+
form = props.form;
163+
return visible ? (
164+
<Field name="foo" component="input" data-testid="foo" />
165+
) : null;
166+
}}
167+
</Form>
168+
);
169+
170+
const { rerender } = render(ui(true));
171+
172+
act(() => form.change("foo", undefined));
173+
rerender(ui(false));
174+
rerender(ui(true));
175+
176+
expect(form.getState().values.foo).toBeUndefined();
177+
});
178+
179+
it("preserves a value set before the first mount when destroyOnUnregister is on", () => {
180+
let form;
181+
const ui = (visible) => (
182+
<Form
183+
onSubmit={onSubmitMock}
184+
initialValues={{ foo: "A" }}
185+
destroyOnUnregister
186+
>
187+
{(props) => {
188+
form = props.form;
189+
return visible ? (
190+
<Field name="foo" component="input" data-testid="foo" />
191+
) : null;
192+
}}
193+
</Form>
194+
);
195+
196+
const { getByTestId, rerender } = render(ui(false));
197+
198+
act(() => form.change("foo", "programmatic"));
199+
rerender(ui(true));
200+
201+
expect(form.getState().values.foo).toBe("programmatic");
202+
expect(getByTestId("foo").value).toBe("programmatic");
203+
});
204+
205+
it("does not restore a stale array entry after the list shifted", () => {
206+
let form;
207+
const ui = (len) => (
208+
<Form onSubmit={onSubmitMock} initialValues={arrayInitialValues}>
209+
{(props) => {
210+
form = props.form;
211+
return Array.from({ length: len }).map((_, i) => (
212+
<Field key={i} name={`items[${i}].name`} component="input" />
213+
));
214+
}}
215+
</Form>
216+
);
217+
218+
const { rerender } = render(ui(2));
219+
220+
// what final-form-arrays remove(1) + push(undefined) leaves behind
221+
act(() => form.change("items", [{ name: "a" }]));
222+
rerender(ui(1));
223+
act(() => form.change("items", [{ name: "a" }, undefined]));
224+
rerender(ui(2));
225+
226+
expect(form.getState().values.items[1]).toBeUndefined();
227+
});
228+
229+
it("keeps a typed value when an unrelated prop changes", () => {
230+
let form;
231+
const ui = (data) => (
232+
<Form onSubmit={onSubmitMock} initialValues={{ foo: "original" }}>
233+
{(props) => {
234+
form = props.form;
235+
return <Field name="foo" component="input" data={data} />;
236+
}}
237+
</Form>
238+
);
239+
240+
const { rerender } = render(ui({ tick: 1 }));
241+
242+
act(() => form.change("foo", "typed-by-user"));
243+
rerender(ui({ tick: 2 }));
244+
245+
expect(form.getState().values.foo).toBe("typed-by-user");
246+
});
247+
248+
it("does not overwrite a modified field when a non-matching initialValue arrives", () => {
249+
let form;
250+
const ui = (initialValue) => (
251+
<Form onSubmit={onSubmitMock} initialValues={{ foo: "original" }}>
252+
{(props) => {
253+
form = props.form;
254+
return (
255+
<Field name="foo" component="input" initialValue={initialValue} />
256+
);
257+
}}
258+
</Form>
259+
);
260+
261+
const { rerender } = render(ui("original"));
262+
263+
act(() => form.change("foo", "typed-by-user"));
264+
rerender(ui("server-said-B"));
265+
266+
expect(form.getState().values.foo).toBe("typed-by-user");
267+
});
268+
269+
it("still reapplies a field-level initialValue after destroyOnUnregister wipes it", () => {
270+
const ui = (visible) => (
271+
<Form onSubmit={onSubmitMock} destroyOnUnregister>
272+
{() =>
273+
visible ? (
274+
<Field
275+
name="nickname"
276+
component="input"
277+
initialValue="erik"
278+
data-testid="nickname"
279+
/>
280+
) : null
281+
}
282+
</Form>
283+
);
284+
285+
const { getByTestId, rerender } = render(ui(true));
286+
287+
rerender(ui(false));
288+
rerender(ui(true));
289+
290+
expect(getByTestId("nickname").value).toBe("erik");
291+
});
292+
293+
it("still seeds a path that has no value at all", () => {
294+
let form;
295+
const ui = (visible) => (
296+
<Form onSubmit={onSubmitMock} destroyOnUnregister>
297+
{(props) => {
298+
form = props.form;
299+
return visible ? (
300+
<Field
301+
name="late"
302+
component="input"
303+
initialValue="seeded"
304+
data-testid="late"
305+
/>
306+
) : null;
307+
}}
308+
</Form>
309+
);
310+
311+
const { getByTestId, rerender } = render(ui(false));
312+
expect(form.getState().values.late).toBeUndefined();
313+
314+
rerender(ui(true));
315+
316+
expect(getByTestId("late").value).toBe("seeded");
317+
expect(form.getState().values.late).toBe("seeded");
318+
});
319+
});

src/useField.ts

Lines changed: 14 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -162,15 +162,18 @@ function useField<
162162
// Check if field state exists in the form before registering
163163
const existingFieldState = form.getFieldState(name as keyof FormValues);
164164

165-
// If field doesn't exist in form state, it means the field was destroyed
166-
// (e.g., by destroyOnUnregister in StrictMode). In this case, we need to
167-
// explicitly set the value before registering to ensure the initial value
168-
// is applied, even if form thinks initialValues haven't changed.
165+
// FIX #1095: a missing field state does not mean the value was destroyed.
166+
// Final Form drops `fields[name]` on the last unregister either way, so a
167+
// path written only through `form.change()` looks the same as a wiped one.
168+
// Values are deleted on unregister only under `destroyOnUnregister`, so that
169+
// flag plus an empty path is what this reseed exists to repair (#1031).
169170
if (!existingFieldState) {
170171
const formState = form.getState();
172+
const currentValue = formState.values ? getIn(formState.values, name) : undefined;
171173
const formInitialValue = formState.initialValues ? getIn(formState.initialValues, name) : undefined;
172174
const valueToSet = formInitialValue !== undefined ? formInitialValue : initialValue;
173-
if (valueToSet !== undefined) {
175+
const valueWasDestroyed = form.destroyOnUnregister && currentValue === undefined;
176+
if (valueToSet !== undefined && valueWasDestroyed) {
174177
form.change(name as keyof FormValues, valueToSet);
175178
}
176179
}
@@ -241,6 +244,12 @@ function useField<
241244
form.pauseValidation();
242245
}
243246
try {
247+
// registerField only adopts a new `initialValue` while the field
248+
// is pristine, so restore the old initial to satisfy that check.
249+
// This used to happen implicitly, as a side effect of the mount
250+
// effect above resetting on every re-registration. Safe here:
251+
// `currentValue` already equals `initialValue`.
252+
form.change(name as keyof FormValues, currentFormInitial);
244253
// Manually update initialValues via registerField with silent: false
245254
// to force notification
246255
const unsubscribe = form.registerField(

0 commit comments

Comments
 (0)