diff --git a/src/useField.issue-984.test.js b/src/useField.issue-984.test.js new file mode 100644 index 0000000..2e6b69b --- /dev/null +++ b/src/useField.issue-984.test.js @@ -0,0 +1,55 @@ +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"); + }, []); + 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").textContent).toBe("UpdatedByField1"); + }); + }); +}); diff --git a/src/useField.ts b/src/useField.ts index 3f97a8d..32f948e 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,71 @@ 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 + const unsubscribe = form.registerField( + name as keyof FormValues, + () => {}, + {}, + { initialValue } + ); + // Immediately unsubscribe to avoid orphan subscriber + unsubscribe(); + } finally { + form.resumeValidation(); + } + } + } + } + } + }, [initialValue, name, form]); + const meta: any = {}; addLazyFieldMetaState(meta, state); const getInputValue = () => { @@ -245,7 +316,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 +325,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 +353,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();