diff --git a/src/useField.issue-1095.test.js b/src/useField.issue-1095.test.js new file mode 100644 index 0000000..0aa98fc --- /dev/null +++ b/src/useField.issue-1095.test.js @@ -0,0 +1,394 @@ +import * as React from "react"; +import { render, act } from "@testing-library/react"; +import "@testing-library/jest-dom"; +import Form from "./ReactFinalForm"; +import Field from "./Field"; + +const onSubmitMock = () => {}; +const arrayInitialValues = { items: [{ name: "a" }, { name: "b" }] }; +const nestedInitialValues = { parent: { child: { value: null } } }; +const listInitialValues = { + items: [ + { id: "a", label: "A" }, + { id: "b", label: "B" }, + { id: "c", label: "C" }, + ], +}; + +// Stable React key per item, positional field name per index. A row that moves +// keeps its instance and only changes its `name`, so the registration effect +// re-runs against a path final-form dropped during the same commit's cleanup. +const renderRows = (values) => + values.items.map((item, index) => ( + + )); + +describe("useField issue #1095", () => { + it("does not overwrite a value set through change() when a field first mounts at that path", () => { + let form; + const { getByTestId, rerender } = render( +
+ {(props) => { + form = props.form; + return null; + }} +
, + ); + + act(() => form.change("foo", "B")); + + rerender( +
+ {() => } + , + ); + + expect(form.getState().values.foo).toBe("B"); + expect(getByTestId("foo").value).toBe("B"); + }); + + it("does not overwrite a nested value set through a parent field (wizard case)", () => { + let form; + const Step = ({ showLeaf }) => + showLeaf ? ( + + ) : ( + null} /> + ); + + const ui = (showLeaf) => ( +
+ {(props) => { + form = props.form; + return ; + }} + + ); + + const { getByTestId, rerender } = render(ui(false)); + + act(() => form.change("parent.child.value", "chosen")); + + rerender(ui(true)); + + expect(form.getState().values.parent.child.value).toBe("chosen"); + expect(getByTestId("leaf").value).toBe("chosen"); + }); + + it("preserves a changed value across unmount and remount of the field", () => { + let form; + const ui = (visible) => ( +
+ {(props) => { + form = props.form; + return visible ? ( + + ) : null; + }} + + ); + + const { getByTestId, rerender } = render(ui(true)); + + act(() => form.change("foo", "B")); + rerender(ui(false)); + rerender(ui(true)); + + expect(form.getState().values.foo).toBe("B"); + expect(getByTestId("foo").value).toBe("B"); + }); + + it("still restores the initial value on remount when destroyOnUnregister is on (#1031)", () => { + let form; + const ui = (visible) => ( +
+ {(props) => { + form = props.form; + return visible ? ( + + ) : null; + }} + + ); + + const { getByTestId, rerender } = render(ui(true)); + + act(() => form.change("foo", "B")); + rerender(ui(false)); + + // destroyOnUnregister deleted the value on unregister + expect(form.getState().values.foo).toBeUndefined(); + + rerender(ui(true)); + + expect(form.getState().values.foo).toBe("A"); + expect(getByTestId("foo").value).toBe("A"); + }); + + it("keeps initial values in StrictMode with destroyOnUnregister (#1031)", () => { + const { getByTestId } = render( + +
+ {() => } + +
, + ); + + expect(getByTestId("foo").value).toBe("A"); + }); + + it("keeps a changed value in StrictMode without destroyOnUnregister", () => { + let form; + const ui = (visible) => ( + +
+ {(props) => { + form = props.form; + return visible ? ( + + ) : null; + }} + +
+ ); + + const { getByTestId, rerender } = render(ui(false)); + + act(() => form.change("foo", "B")); + rerender(ui(true)); + + expect(form.getState().values.foo).toBe("B"); + expect(getByTestId("foo").value).toBe("B"); + }); + + it("does not restore the initial value after an intentional change to undefined", () => { + let form; + const ui = (visible) => ( +
+ {(props) => { + form = props.form; + return visible ? ( + + ) : null; + }} + + ); + + const { rerender } = render(ui(true)); + + act(() => form.change("foo", undefined)); + rerender(ui(false)); + rerender(ui(true)); + + expect(form.getState().values.foo).toBeUndefined(); + }); + + it("preserves a value set before the first mount when destroyOnUnregister is on", () => { + let form; + const ui = (visible) => ( +
+ {(props) => { + form = props.form; + return visible ? ( + + ) : null; + }} + + ); + + const { getByTestId, rerender } = render(ui(false)); + + act(() => form.change("foo", "programmatic")); + rerender(ui(true)); + + expect(form.getState().values.foo).toBe("programmatic"); + expect(getByTestId("foo").value).toBe("programmatic"); + }); + + it("does not restore a stale array entry after the list shifted", () => { + let form; + const ui = (len) => ( +
+ {(props) => { + form = props.form; + return Array.from({ length: len }).map((_, i) => ( + + )); + }} + + ); + + const { rerender } = render(ui(2)); + + // what final-form-arrays remove(1) + push(undefined) leaves behind + act(() => form.change("items", [{ name: "a" }])); + rerender(ui(1)); + act(() => form.change("items", [{ name: "a" }, undefined])); + rerender(ui(2)); + + expect(form.getState().values.items[1]).toBeUndefined(); + }); + + it("keeps a typed value when an unrelated prop changes", () => { + let form; + const ui = (data) => ( +
+ {(props) => { + form = props.form; + return ; + }} + + ); + + const { rerender } = render(ui({ tick: 1 })); + + act(() => form.change("foo", "typed-by-user")); + rerender(ui({ tick: 2 })); + + expect(form.getState().values.foo).toBe("typed-by-user"); + }); + + it("does not overwrite a modified field when a non-matching initialValue arrives", () => { + let form; + const ui = (initialValue) => ( +
+ {(props) => { + form = props.form; + return ( + + ); + }} + + ); + + const { rerender } = render(ui("original")); + + act(() => form.change("foo", "typed-by-user")); + rerender(ui("server-said-B")); + + expect(form.getState().values.foo).toBe("typed-by-user"); + }); + + it("still reapplies a field-level initialValue after destroyOnUnregister wipes it", () => { + const ui = (visible) => ( +
+ {() => + visible ? ( + + ) : null + } + + ); + + const { getByTestId, rerender } = render(ui(true)); + + rerender(ui(false)); + rerender(ui(true)); + + expect(getByTestId("nickname").value).toBe("erik"); + }); + + it("still seeds a path that has no value at all", () => { + let form; + const ui = (visible) => ( +
+ {(props) => { + form = props.form; + return visible ? ( + + ) : null; + }} + + ); + + const { getByTestId, rerender } = render(ui(false)); + expect(form.getState().values.late).toBeUndefined(); + + rerender(ui(true)); + + expect(getByTestId("late").value).toBe("seeded"); + expect(form.getState().values.late).toBe("seeded"); + }); + + it("keeps edited labels with their rows when an item is inserted at the head", () => { + let form; + const { getByTestId } = render( +
+ {(props) => { + form = props.form; + return renderRows(props.values); + }} +
, + ); + + act(() => form.change("items[0].label", "A-edited")); + + act(() => + form.change("items", [ + { id: "d", label: "D" }, + ...form.getState().values.items, + ]), + ); + + expect(form.getState().values.items).toEqual([ + { id: "d", label: "D" }, + { id: "a", label: "A-edited" }, + { id: "b", label: "B" }, + { id: "c", label: "C" }, + ]); + expect(getByTestId("label-d").value).toBe("D"); + expect(getByTestId("label-a").value).toBe("A-edited"); + }); + + it("keeps edited labels with their rows when an item is removed from the middle", () => { + let form; + const { getByTestId } = render( +
+ {(props) => { + form = props.form; + return renderRows(props.values); + }} +
, + ); + + act(() => form.change("items[2].label", "C-edited")); + + act(() => { + const items = form.getState().values.items; + form.change("items", [items[0], items[2]]); + }); + + expect(form.getState().values.items).toEqual([ + { id: "a", label: "A" }, + { id: "c", label: "C-edited" }, + ]); + expect(getByTestId("label-c").value).toBe("C-edited"); + }); +}); diff --git a/src/useField.ts b/src/useField.ts index db7b27a..72f6987 100644 --- a/src/useField.ts +++ b/src/useField.ts @@ -162,15 +162,18 @@ function useField< // Check if field state exists in the form before registering const existingFieldState = form.getFieldState(name as keyof FormValues); - // If field doesn't exist in form state, it means the field was destroyed - // (e.g., by destroyOnUnregister in StrictMode). In this case, we need to - // explicitly set the value before registering to ensure the initial value - // is applied, even if form thinks initialValues haven't changed. + // FIX #1095: a missing field state does not mean the value was destroyed. + // Final Form drops `fields[name]` on the last unregister either way, so a + // path written only through `form.change()` looks the same as a wiped one. + // Values are deleted on unregister only under `destroyOnUnregister`, so that + // flag plus an empty path is what this reseed exists to repair (#1031). if (!existingFieldState) { const formState = form.getState(); + const currentValue = formState.values ? getIn(formState.values, name) : undefined; const formInitialValue = formState.initialValues ? getIn(formState.initialValues, name) : undefined; const valueToSet = formInitialValue !== undefined ? formInitialValue : initialValue; - if (valueToSet !== undefined) { + const valueWasDestroyed = form.destroyOnUnregister && currentValue === undefined; + if (valueToSet !== undefined && valueWasDestroyed) { form.change(name as keyof FormValues, valueToSet); } } @@ -241,6 +244,12 @@ function useField< form.pauseValidation(); } try { + // registerField only adopts a new `initialValue` while the field + // is pristine, so restore the old initial to satisfy that check. + // This used to happen implicitly, as a side effect of the mount + // effect above resetting on every re-registration. Safe here: + // `currentValue` already equals `initialValue`. + form.change(name as keyof FormValues, currentFormInitial); // Manually update initialValues via registerField with silent: false // to force notification const unsubscribe = form.registerField(