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 - } -} 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", }, }, 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"); + }); + }); +}); diff --git a/src/useField.issue-984.test.js b/src/useField.issue-984.test.js new file mode 100644 index 0000000..1be6934 --- /dev/null +++ b/src/useField.issue-984.test.js @@ -0,0 +1,56 @@ +import * as React from "react"; +import { render, waitFor } from "@testing-library/react"; +import "@testing-library/jest-dom"; +import Form from "./ReactFinalForm"; +import { useField } from "./index"; + +const onSubmitMock = (_values) => {}; + +describe("useField issue #984", () => { + // 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"); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + return null; + }; + + const { getByTestId } = render( +
+ {() => ( + + + + + + )} + + ); + + // After useEffect runs, Field2 should see the updated value + // This is the bug: Field2 sees stale "InitialField1" instead + await waitFor(() => { + expect(getByTestId("field1-value")).toHaveTextContent("UpdatedByField1"); + }); + }); +}); diff --git a/src/useField.ts b/src/useField.ts index 3f97a8d..cf20e13 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,81 @@ 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(() => { + // Use the configured isEqual function (respects custom equality for objects/arrays) + const isEqual = configRef.current.isEqual || ((a: any, b: any) => a === b); + 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(); + const currentFormInitial = formState.initialValues + ? getIn(formState.initialValues, name) + : undefined; + + // Only update if the new initialValue differs from current form initial + if (!isEqual(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 (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: + // - 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. + const wasValidationPaused = form.isValidationPaused(); + if (!wasValidationPaused) { + form.pauseValidation(); + } + try { + // Manually update initialValues via registerField with silent: false + // to force notification + const unsubscribe = form.registerField( + name as keyof FormValues, + () => {}, + {}, + { + initialValue, + isEqual: configRef.current.isEqual, + } + ); + // Immediately unsubscribe to avoid orphan subscriber + unsubscribe(); + } finally { + if (!wasValidationPaused) { + form.resumeValidation(); + } + } + } + } + } + } + }, [initialValue, name, form]); + const meta: any = {}; addLazyFieldMetaState(meta, state); const getInputValue = () => { @@ -245,7 +326,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 +335,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 +363,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();