From e23f8b528f95849c3bfc4ec656001a7d02301acc Mon Sep 17 00:00:00 2001 From: Christian Aurich Date: Thu, 13 Aug 2026 17:51:11 -0300 Subject: [PATCH 1/2] 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 --- src/useField.issue-1095.test.js | 319 ++++++++++++++++++++++++++++++++ src/useField.ts | 19 +- 2 files changed, 333 insertions(+), 5 deletions(-) create mode 100644 src/useField.issue-1095.test.js diff --git a/src/useField.issue-1095.test.js b/src/useField.issue-1095.test.js new file mode 100644 index 0000000..f5335c0 --- /dev/null +++ b/src/useField.issue-1095.test.js @@ -0,0 +1,319 @@ +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 } } }; + +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"); + }); +}); 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( From 6b92c45c2e9b46031ce4285b4ff2372bc9315fd5 Mon Sep 17 00:00:00 2001 From: Christian Aurich Date: Fri, 21 Aug 2026 14:37:56 -0300 Subject: [PATCH 2/2] test: cover positional name shifts with stable React keys --- src/useField.issue-1095.test.js | 75 +++++++++++++++++++++++++++++++++ 1 file changed, 75 insertions(+) diff --git a/src/useField.issue-1095.test.js b/src/useField.issue-1095.test.js index f5335c0..0aa98fc 100644 --- a/src/useField.issue-1095.test.js +++ b/src/useField.issue-1095.test.js @@ -7,6 +7,26 @@ 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", () => { @@ -316,4 +336,59 @@ describe("useField issue #1095", () => { 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"); + }); });