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(
+
,
+ );
+
+ 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) => (
+
+ );
+
+ 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) => (
+
+ );
+
+ 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) => (
+
+ );
+
+ 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) => (
+
+
+
+ );
+
+ 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) => (
+
+ );
+
+ 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) => (
+
+ );
+
+ 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) => (
+
+ );
+
+ 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) => (
+
+ );
+
+ 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) => (
+
+ );
+
+ 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) => (
+
+ );
+
+ 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) => (
+
+ );
+
+ 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(
+ ,
+ );
+
+ 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(
+ ,
+ );
+
+ 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(