From 4295d412714a08fadf514bb1b0746c68bd0882bc Mon Sep 17 00:00:00 2001 From: Erik Rasmussen Date: Tue, 7 Apr 2026 14:05:32 +0200 Subject: [PATCH 1/8] Fix #984: useField returns stale values when sibling updates form in useEffect Problem: When a parent/sibling component's useEffect changes a form value, other useField hooks see stale values because their subscription hasn't registered yet. The initial state had no-op blur/change/focus handlers. Fix: Replace no-op handlers with live form-backed handlers that call form.blur/form.change/form.focus directly, so effect-time changes propagate immediately before the permanent subscription is registered. Also includes #988 fix for radio button dirty state when initialValue changes. --- src/useField.issue-984.test.js | 59 ++++++++++++++++++++++ src/useField.ts | 89 ++++++++++++++++++++++++++++++---- 2 files changed, 139 insertions(+), 9 deletions(-) create mode 100644 src/useField.issue-984.test.js diff --git a/src/useField.issue-984.test.js b/src/useField.issue-984.test.js new file mode 100644 index 0000000..8fa669e --- /dev/null +++ b/src/useField.issue-984.test.js @@ -0,0 +1,59 @@ +import * as React from "react"; +import { render, cleanup } from "@testing-library/react"; +import "@testing-library/jest-dom"; +import Form from "./ReactFinalForm"; +import { useField } from "./index"; + +const onSubmitMock = (_values) => {}; + +describe("useField issue #984", () => { + afterEach(cleanup); + + // https://github.com/final-form/react-final-form/issues/984 + // When a parent component's useEffect changes a form value, + // sibling components' useField should receive the updated value. + it("should get newest value when sibling updates form in useEffect", async () => { + const Field1 = () => { + const { input } = useField("field1"); + return ; + }; + + const Field2 = () => { + const { input } = useField("field1", { subscription: { value: true } }); + // Should show "UpdatedByField1" after ParentWithEffect's useEffect runs + return {input.value}; + }; + + const ParentWithEffect = () => { + const { input } = useField("field1"); + React.useEffect(() => { + // Simulate programmatic change during effect phase + input.onChange("UpdatedByField1"); + }, []); + return null; + }; + + const { getByTestId } = render( +
+ {() => ( + + + + + + )} + + ); + + // After useEffect runs, Field2 should see the updated value + // This is the bug: Field2 sees stale "InitialField1" instead + await (async () => { + // Wait a bit for effects to settle + await new Promise((resolve) => setTimeout(resolve, 100)); + expect(getByTestId("field1-value").textContent).toBe("UpdatedByField1"); + })(); + }); +}); diff --git a/src/useField.ts b/src/useField.ts index 3f97a8d..1b8f60f 100644 --- a/src/useField.ts +++ b/src/useField.ts @@ -126,13 +126,19 @@ function useField< return { active: false, - blur: () => { }, - change: () => { }, + blur: () => { + form.blur(name as keyof FormValues); + }, + change: (value) => { + form.change(name as keyof FormValues, value); + }, data: data || {}, dirty: false, dirtySinceLastSubmit: false, error: undefined, - focus: () => { }, + focus: () => { + form.focus(name as keyof FormValues); + }, initial: initialStateValue, invalid: false, length: undefined, @@ -184,6 +190,69 @@ function useField< // eslint-disable-next-line react-hooks/exhaustive-deps }, [name, data, defaultValue, initialValue]); + // FIX #988: When initialValue prop changes, update the form's initialValues + // for this field. This ensures that when a parent component updates initialValues + // after a save operation, the field becomes pristine if the value matches. + const prevInitialValueRef = React.useRef(initialValue); + React.useEffect(() => { + // Only run when initialValue actually changes (not on mount) + if ( + prevInitialValueRef.current !== initialValue && + initialValue !== undefined + ) { + prevInitialValueRef.current = initialValue; + + // Get current form state + const formState = form.getState(); + const currentFormInitial = formState.initialValues + ? getIn(formState.initialValues, name) + : undefined; + + // Only update if the new initialValue differs from current form initial + if (initialValue !== currentFormInitial) { + const currentValue = getIn(formState.values, name); + + // If the current value matches the new initial value, update the form's + // initialValues to reflect this. This is needed for radio buttons where + // the user changes the value, then the parent saves and passes back the + // new initial value that matches what the user selected. + // + // We need to manually update formState.initialValues and notify listeners. + // Final Form doesn't expose a public API for this, so we use internal state. + const fieldState = form.getFieldState(name as keyof FormValues); + if (fieldState) { + // Force an update through the field subscriber by triggering a change + // to the same value, which will recalculate dirty state with new initial + if (currentValue === initialValue) { + // The value matches the new initial, so field should become pristine. + // Re-register with new initialValue to update formState.initialValues. + // Final Form's registerField will update initialValues when: + // - value === old initial (meaning pristine before) + // We need to handle the case where value === new initial but value !== old initial + // + // Workaround: We need to update formState.initialValues directly. + // The only public API is form.setConfig('initialValues', ...) but that + // resets ALL values. Instead, we use a workaround: + // Trigger a re-registration which will update initialValues for this field. + form.pauseValidation(); + try { + // Manually update initialValues via registerField with silent: false + // to force notification + form.registerField( + name as keyof FormValues, + () => {}, + {}, + { initialValue } + ); + } finally { + form.resumeValidation(); + } + } + } + } + } + }, [initialValue, name, form]); + const meta: any = {}; addLazyFieldMetaState(meta, state); const getInputValue = () => { @@ -245,7 +314,7 @@ function useField< const input: FieldInputProps = { name, onBlur: useConstantCallback((_event?: React.FocusEvent) => { - state.blur(); + form.blur(name as keyof FormValues); if (formatOnBlur) { /** * Here we must fetch the value directly from Final Form because we cannot @@ -254,9 +323,9 @@ function useField< * before calling `onBlur()`, but before the field has had a chance to receive * the value update from Final Form. */ - const fieldState = form.getFieldState(state.name as keyof FormValues); + const fieldState = form.getFieldState(name as keyof FormValues); if (fieldState) { - state.change(format(fieldState.value, state.name)); + form.change(name as keyof FormValues, format(fieldState.value, name)); } } }), @@ -282,14 +351,16 @@ function useField< } } + const currentValue = + form.getFieldState(name as keyof FormValues)?.value ?? state.value; const value: any = event && event.target - ? getValue(event, state.value, _value, isReactNative) + ? getValue(event, currentValue, _value, isReactNative) : event; - state.change(parse(value, name)); + form.change(name as keyof FormValues, parse(value, name)); }), onFocus: useConstantCallback((_event?: React.FocusEvent) => - state.focus(), + form.focus(name as keyof FormValues), ), get value() { return getInputValue(); From ab65f3a00f0b869b5f03efcb9c7ea937c7374c9a Mon Sep 17 00:00:00 2001 From: Erik Rasmussen Date: Thu, 9 Apr 2026 12:32:05 +0200 Subject: [PATCH 2/8] Address CodeRabbit feedback: use waitFor, cleanup orphan subscription --- src/useField.issue-984.test.js | 10 +++------- src/useField.ts | 4 +++- 2 files changed, 6 insertions(+), 8 deletions(-) diff --git a/src/useField.issue-984.test.js b/src/useField.issue-984.test.js index 8fa669e..2e6b69b 100644 --- a/src/useField.issue-984.test.js +++ b/src/useField.issue-984.test.js @@ -1,5 +1,5 @@ import * as React from "react"; -import { render, cleanup } from "@testing-library/react"; +import { render, waitFor } from "@testing-library/react"; import "@testing-library/jest-dom"; import Form from "./ReactFinalForm"; import { useField } from "./index"; @@ -7,8 +7,6 @@ import { useField } from "./index"; const onSubmitMock = (_values) => {}; describe("useField issue #984", () => { - afterEach(cleanup); - // https://github.com/final-form/react-final-form/issues/984 // When a parent component's useEffect changes a form value, // sibling components' useField should receive the updated value. @@ -50,10 +48,8 @@ describe("useField issue #984", () => { // After useEffect runs, Field2 should see the updated value // This is the bug: Field2 sees stale "InitialField1" instead - await (async () => { - // Wait a bit for effects to settle - await new Promise((resolve) => setTimeout(resolve, 100)); + await waitFor(() => { expect(getByTestId("field1-value").textContent).toBe("UpdatedByField1"); - })(); + }); }); }); diff --git a/src/useField.ts b/src/useField.ts index 1b8f60f..32f948e 100644 --- a/src/useField.ts +++ b/src/useField.ts @@ -238,12 +238,14 @@ function useField< try { // Manually update initialValues via registerField with silent: false // to force notification - form.registerField( + const unsubscribe = form.registerField( name as keyof FormValues, () => {}, {}, { initialValue } ); + // Immediately unsubscribe to avoid orphan subscriber + unsubscribe(); } finally { form.resumeValidation(); } From d7ffb05fe01d1e1faced261e7373542b1d9a9bfe Mon Sep 17 00:00:00 2001 From: Erik Rasmussen Date: Thu, 30 Apr 2026 12:18:48 +0200 Subject: [PATCH 3/8] fix: address CodeRabbit review comments --- src/useField.issue-984.test.js | 3 ++- src/useField.ts | 8 +++++--- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/src/useField.issue-984.test.js b/src/useField.issue-984.test.js index 2e6b69b..1be6934 100644 --- a/src/useField.issue-984.test.js +++ b/src/useField.issue-984.test.js @@ -27,6 +27,7 @@ describe("useField issue #984", () => { React.useEffect(() => { // Simulate programmatic change during effect phase input.onChange("UpdatedByField1"); + // eslint-disable-next-line react-hooks/exhaustive-deps }, []); return null; }; @@ -49,7 +50,7 @@ describe("useField issue #984", () => { // After useEffect runs, Field2 should see the updated value // This is the bug: Field2 sees stale "InitialField1" instead await waitFor(() => { - expect(getByTestId("field1-value").textContent).toBe("UpdatedByField1"); + expect(getByTestId("field1-value")).toHaveTextContent("UpdatedByField1"); }); }); }); diff --git a/src/useField.ts b/src/useField.ts index 32f948e..99a6c94 100644 --- a/src/useField.ts +++ b/src/useField.ts @@ -195,9 +195,11 @@ function useField< // after a save operation, the field becomes pristine if the value matches. const prevInitialValueRef = React.useRef(initialValue); React.useEffect(() => { + // Use the configured isEqual function (respects custom equality for objects/arrays) + const isEqual = configRef.current.isEqual || ((a: any, b: any) => a === b); // Only run when initialValue actually changes (not on mount) if ( - prevInitialValueRef.current !== initialValue && + !isEqual(prevInitialValueRef.current, initialValue) && initialValue !== undefined ) { prevInitialValueRef.current = initialValue; @@ -209,7 +211,7 @@ function useField< : undefined; // Only update if the new initialValue differs from current form initial - if (initialValue !== currentFormInitial) { + if (!isEqual(initialValue, currentFormInitial)) { const currentValue = getIn(formState.values, name); // If the current value matches the new initial value, update the form's @@ -223,7 +225,7 @@ function useField< if (fieldState) { // Force an update through the field subscriber by triggering a change // to the same value, which will recalculate dirty state with new initial - if (currentValue === initialValue) { + if (isEqual(currentValue, initialValue)) { // The value matches the new initial, so field should become pristine. // Re-register with new initialValue to update formState.initialValues. // Final Form's registerField will update initialValues when: From fa69c1bcd2384309e5d29afe26786215c4195e46 Mon Sep 17 00:00:00 2001 From: erikras-richard-agent Date: Fri, 1 May 2026 10:06:51 +0200 Subject: [PATCH 4/8] fix: remove legacy .eslintrc that conflicts with eslint.config.mjs (fixes lint CI) --- .eslintrc | 10 ---------- 1 file changed, 10 deletions(-) delete mode 100755 .eslintrc diff --git a/.eslintrc b/.eslintrc deleted file mode 100755 index 7939e29..0000000 --- a/.eslintrc +++ /dev/null @@ -1,10 +0,0 @@ -{ - "extends": "react-app", - "plugins": ["react-hooks"], - "rules": { - "jsx-a11y/href-no-hash": 0, - "react-hooks/rules-of-hooks": "error", - "react-hooks/exhaustive-deps": "warn", - "import/no-anonymous-default-export": 0 - } -} From bcc1ef945356a39324e9e07b85f48dd381cd656f Mon Sep 17 00:00:00 2001 From: erikras-richard-agent Date: Fri, 1 May 2026 19:16:46 +0200 Subject: [PATCH 5/8] fix: add react-hooks plugin to JS files config in eslint.config.mjs --- eslint.config.mjs | 3 +++ 1 file changed, 3 insertions(+) diff --git a/eslint.config.mjs b/eslint.config.mjs index f6b1a2e..ec66009 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -152,12 +152,15 @@ export default [ }, plugins: { react: reactPlugin, + "react-hooks": reactHooks, }, rules: { "no-undef": "error", "react/jsx-uses-vars": "warn", "react/react-in-jsx-scope": "off", "no-unused-vars": ["warn", { argsIgnorePattern: "^_" }], // Enforce _ prefix for unused vars in JS files + "react-hooks/rules-of-hooks": "error", + "react-hooks/exhaustive-deps": "warn", }, }, From 4fa6600a2b3ffa33ef8084030f329390652b8448 Mon Sep 17 00:00:00 2001 From: Erik Rasmussen Date: Tue, 5 May 2026 08:08:04 +0200 Subject: [PATCH 6/8] fix: preserve validation pause state and forward isEqual in re-registration - Check form.isValidationPaused() before calling pauseValidation() so we don't inadvertently resume validation that was already paused externally (e.g. by ReactFinalForm during setup). Mirrors the pattern used in ReactFinalForm.tsx. - Pass configRef.current.isEqual through the temporary registerField call so dirty/pristine calculation uses the field's configured equality comparator rather than the default reference equality. Addresses remaining CodeRabbit review comments. --- src/useField.ts | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/src/useField.ts b/src/useField.ts index 99a6c94..caf8c21 100644 --- a/src/useField.ts +++ b/src/useField.ts @@ -236,7 +236,10 @@ function useField< // The only public API is form.setConfig('initialValues', ...) but that // resets ALL values. Instead, we use a workaround: // Trigger a re-registration which will update initialValues for this field. - form.pauseValidation(); + const wasValidationPaused = form.isValidationPaused(); + if (!wasValidationPaused) { + form.pauseValidation(); + } try { // Manually update initialValues via registerField with silent: false // to force notification @@ -244,12 +247,17 @@ function useField< name as keyof FormValues, () => {}, {}, - { initialValue } + { + initialValue, + isEqual: configRef.current.isEqual, + } ); // Immediately unsubscribe to avoid orphan subscriber unsubscribe(); } finally { - form.resumeValidation(); + if (!wasValidationPaused) { + form.resumeValidation(); + } } } } From 171d2ef99a45c5979abace272ba5ee4a5bd58981 Mon Sep 17 00:00:00 2001 From: Erik Rasmussen Date: Tue, 5 May 2026 08:19:01 +0200 Subject: [PATCH 7/8] fix: always advance prevInitialValueRef regardless of undefined MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Previously the ref was only updated inside the 'initialValue !== undefined' branch, so a transition like "foo" → undefined → "foo" would leave the ref stuck at "foo" and the second change would look like a no-op, leaving dirty/pristine state stale. Move the ref update unconditionally before the condition check, while keeping the registration logic gated on 'initialValue !== undefined'. Addresses CodeRabbit review comment. --- src/useField.ts | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/src/useField.ts b/src/useField.ts index caf8c21..cf20e13 100644 --- a/src/useField.ts +++ b/src/useField.ts @@ -197,12 +197,12 @@ function useField< React.useEffect(() => { // Use the configured isEqual function (respects custom equality for objects/arrays) const isEqual = configRef.current.isEqual || ((a: any, b: any) => a === b); - // Only run when initialValue actually changes (not on mount) - if ( - !isEqual(prevInitialValueRef.current, initialValue) && - initialValue !== undefined - ) { - prevInitialValueRef.current = initialValue; + const prevInitialValue = prevInitialValueRef.current; + // Always advance the ref so transitions through `undefined` are tracked + // correctly (e.g. "foo" → undefined → "foo" must re-trigger the block). + prevInitialValueRef.current = initialValue; + // Only run when initialValue actually changes (not on mount) and is defined + if (!isEqual(prevInitialValue, initialValue) && initialValue !== undefined) { // Get current form state const formState = form.getState(); From a42479a42ba3aff4a8a4682ebabc6d21299888b5 Mon Sep 17 00:00:00 2001 From: Erik Rasmussen Date: Tue, 5 May 2026 08:32:37 +0200 Subject: [PATCH 8/8] test: add coverage for dynamic initialValue change behavior (#1085) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add 5 tests covering the initialValue-change effect introduced in this PR: - Field becomes pristine when initialValue changes to match current value - Field value/initialValue update when initialValue prop changes - initialValue transitioning through undefined (value→undefined→value) - Custom isEqual used when comparing initialValue changes - Field stays pristine when unmodified and initialValue changes These tests exercise the prevInitialValueRef tracking, the isEqual-based comparison, and the pauseValidation/resumeValidation guard paths. Improves useField.ts branch coverage from ~68% to ~71%. --- src/useField.initialValue-change.test.js | 297 +++++++++++++++++++++++ 1 file changed, 297 insertions(+) create mode 100644 src/useField.initialValue-change.test.js diff --git a/src/useField.initialValue-change.test.js b/src/useField.initialValue-change.test.js new file mode 100644 index 0000000..e079a3b --- /dev/null +++ b/src/useField.initialValue-change.test.js @@ -0,0 +1,297 @@ +import * as React from "react"; +import { render, fireEvent, act, waitFor } from "@testing-library/react"; +import "@testing-library/jest-dom"; +import Form from "./ReactFinalForm"; +import { useField } from "./index"; + +const onSubmitMock = () => {}; + +describe("useField — dynamic initialValue changes (#1085)", () => { + // Tests for the initialValue-change effect: when the initialValue prop + // changes on a mounted field, dirty/pristine state should update correctly. + + it("field becomes pristine when initialValue changes to match current value", async () => { + let setInitial; + + const TestField = ({ initialValue }) => { + const { input, meta } = useField("myField", { + initialValue, + subscription: { dirty: true, value: true }, + }); + return ( +
+ + {String(meta.dirty)} +
+ ); + }; + + const Wrapper = () => { + const [initial, setInitial_] = React.useState("original"); + setInitial = setInitial_; + return ( +
+ {() => ( + + + + )} + + ); + }; + + const { getByTestId } = render(); + + // Starts pristine + expect(getByTestId("dirty")).toHaveTextContent("false"); + + // Type "updated" → field becomes dirty + fireEvent.change(getByTestId("input"), { target: { value: "updated" } }); + expect(getByTestId("dirty")).toHaveTextContent("true"); + + // Now update initialValue to "updated" — field should become pristine + await act(async () => { + setInitial("updated"); + }); + + await waitFor(() => { + expect(getByTestId("dirty")).toHaveTextContent("false"); + }); + }); + + it("field value and initialValue both update when initialValue prop changes", async () => { + // When initialValue changes and current value differs, re-registration + // updates the form's tracked initialValue for this field. This verifies + // the re-registration path executes without errors. + let setInitial; + + const TestField = ({ initialValue }) => { + const { input, meta } = useField("myField", { + initialValue, + subscription: { dirty: true, value: true }, + }); + return ( +
+ + {String(meta.dirty)} + {input.value} +
+ ); + }; + + const Wrapper = () => { + const [initial, setInitial_] = React.useState("original"); + setInitial = setInitial_; + return ( +
+ {() => ( + + + + )} + + ); + }; + + const { getByTestId } = render(); + + // Type "updated" → field becomes dirty + fireEvent.change(getByTestId("input"), { target: { value: "updated" } }); + expect(getByTestId("dirty")).toHaveTextContent("true"); + + // Change initialValue to "updated" → value matches new initial → pristine + await act(async () => { + setInitial("updated"); + }); + + await waitFor(() => { + expect(getByTestId("dirty")).toHaveTextContent("false"); + }); + + // Value remains what the user typed + expect(getByTestId("value")).toHaveTextContent("updated"); + }); + + it("handles initialValue transitioning through undefined (value→undefined→value)", async () => { + let setInitial; + + const TestField = ({ initialValue }) => { + const { input, meta } = useField("myField", { + initialValue, + subscription: { dirty: true, value: true }, + }); + return ( +
+ + {String(meta.dirty)} +
+ ); + }; + + const Wrapper = () => { + const [initial, setInitial_] = React.useState("original"); + setInitial = setInitial_; + return ( +
+ {() => ( + + + + )} + + ); + }; + + const { getByTestId } = render(); + + expect(getByTestId("dirty")).toHaveTextContent("false"); + + // Type "changed" → field becomes dirty + fireEvent.change(getByTestId("input"), { target: { value: "changed" } }); + expect(getByTestId("dirty")).toHaveTextContent("true"); + + // Transition: "original" → undefined → "changed" + // Without the fix, the ref stays at "original" through the undefined step, + // so "undefined → changed" looks like no change and re-registration is skipped. + // With the fix, the ref advances through undefined so "changed" is detected. + await act(async () => { + setInitial(undefined); + }); + await act(async () => { + setInitial("changed"); + }); + + // initialValue now matches current value → should be pristine + await waitFor(() => { + expect(getByTestId("dirty")).toHaveTextContent("false"); + }); + }); + + it("uses custom isEqual when detecting initialValue changes", async () => { + let setInitial; + // Custom isEqual: objects are equal if their .id matches + const isEqual = (a, b) => { + if (a && b && typeof a === "object" && typeof b === "object") { + return a.id === b.id; + } + return a === b; + }; + + const TestField = ({ initialValue }) => { + const { input, meta } = useField("myField", { + initialValue, + isEqual, + subscription: { dirty: true, value: true }, + }); + return ( +
+ { + try { + input.onChange(JSON.parse(e.target.value)); + } catch { + input.onChange(e.target.value); + } + }} + data-testid="input" + /> + {String(meta.dirty)} +
+ ); + }; + + const Wrapper = () => { + const [initial, setInitial_] = React.useState({ id: 1, label: "One" }); + setInitial = setInitial_; + return ( +
+ {() => ( + + + + )} + + ); + }; + + const { getByTestId } = render(); + + // Starts pristine + expect(getByTestId("dirty")).toHaveTextContent("false"); + + // Change value to { id: 2 } → dirty + fireEvent.change(getByTestId("input"), { + target: { value: JSON.stringify({ id: 2, label: "Two" }) }, + }); + expect(getByTestId("dirty")).toHaveTextContent("true"); + + // Change initialValue to { id: 2, label: "Different label" } + // isEqual treats id:2 === id:2, so initialValue "matches" current value + // → field should become pristine + await act(async () => { + setInitial({ id: 2, label: "Different label" }); + }); + + await waitFor(() => { + expect(getByTestId("dirty")).toHaveTextContent("false"); + }); + }); + + it("field stays dirty when new initialValue does not match current value (no re-registration shortcut)", async () => { + // When initialValue changes to X but the current value is Y (X ≠ Y), + // the isEqual(currentValue, initialValue) check in the effect returns false + // so the re-registration shortcut is skipped. + // We verify the effect ran (no errors) and the field tracks dirty state. + let setInitial; + + const TestField = ({ initialValue }) => { + const { input, meta } = useField("myField", { + initialValue, + subscription: { dirty: true, pristine: true, value: true }, + }); + return ( +
+ + {String(meta.dirty)} + {String(meta.pristine)} +
+ ); + }; + + const Wrapper = () => { + const [initial, setInitial_] = React.useState("original"); + setInitial = setInitial_; + return ( +
+ {() => ( + + + + )} + + ); + }; + + const { getByTestId } = render(); + + // Starts pristine + expect(getByTestId("dirty")).toHaveTextContent("false"); + expect(getByTestId("pristine")).toHaveTextContent("true"); + + // Change initialValue to something new — field was never modified so it + // adopts the new initialValue and remains pristine. + await act(async () => { + setInitial("new-initial"); + }); + + await waitFor(() => { + // Field stays pristine with new initialValue (value tracks initialValue) + expect(getByTestId("pristine")).toHaveTextContent("true"); + }); + }); +});