diff --git a/package-lock.json b/package-lock.json index eaf8d77..3e95fa1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "remix-hook-form", - "version": "1.1.0", + "version": "1.1.2", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "remix-hook-form", - "version": "1.1.0", + "version": "1.1.2", "license": "MIT", "devDependencies": { "@hookform/resolvers": "^3.1.0", diff --git a/src/hook/index.test.tsx b/src/hook/index.test.tsx index 4efb6ea..b43ca4a 100644 --- a/src/hook/index.test.tsx +++ b/src/hook/index.test.tsx @@ -8,6 +8,7 @@ import { } from "@testing-library/react"; import { RemixFormProvider, useRemixForm, useRemixFormContext } from "./index"; import React from "react"; +import * as remixRun from "@remix-run/react"; const submitMock = vi.fn(); vi.mock("@remix-run/react", () => ({ @@ -15,6 +16,10 @@ vi.mock("@remix-run/react", () => ({ useActionData: () => ({}), })); +const mockUseActionData = vi + .spyOn(remixRun, "useActionData") + .mockImplementation(() => ({})); + describe("useRemixForm", () => { it("should return all the same output that react-hook-form returns", () => { const { result } = renderHook(() => useRemixForm({})); @@ -116,4 +121,87 @@ describe("RemixFormProvider", () => { expect(spy).toHaveBeenCalled(); }); + + it("should merge useActionData error on submission only", async () => { + const mockError = { + userName: { message: "UserName required", type: "custom" }, + }; + + const enum Value_Key { + USERNAME = "userName", + SCREEN_NAME = "screenName", + } + + const defaultValues = { + [Value_Key.USERNAME]: "", + [Value_Key.SCREEN_NAME]: "", + }; + + const { result, rerender } = renderHook(() => + useRemixForm({ + mode: "onSubmit", + reValidateMode: "onChange", + submitConfig: { + action: "/submit", + }, + defaultValues, + }) + ); + + // Set useActionData mock after initial render, to simulate a server error + mockUseActionData.mockImplementation(() => mockError); + + act(() => { + result.current.setValue(Value_Key.SCREEN_NAME, "priceIsWrong"); + }); + + act(() => { + result.current.handleSubmit(); + }); + + // Tests that error message is merged. + await waitFor(() => { + expect(result.current.formState.errors[Value_Key.USERNAME]?.message).toBe( + mockError[Value_Key.USERNAME].message + ); + }); + + act(() => { + result.current.setValue(Value_Key.USERNAME, "Bob Barker"); + // Simulates revalidation onChange + result.current.clearErrors(Value_Key.USERNAME); + }); + + rerender(); + + // This test that error is cleared after state change and not reemerged from useActionData + await waitFor(() => { + expect(result.current.getValues(Value_Key.USERNAME)).toBe("Bob Barker"); + }); + + await waitFor(() => { + expect( + result.current.formState.errors[Value_Key.USERNAME]?.message + ).toBeUndefined(); + }); + + const newScreenName = "CaptainJackSparrow"; + + act(() => { + result.current.setValue(Value_Key.SCREEN_NAME, newScreenName); + }); + + // This test that other state changes do not reemerged from useActionData + await waitFor(() => { + expect(result.current.getValues(Value_Key.SCREEN_NAME)).toBe( + newScreenName + ); + }); + + await waitFor(() => { + expect( + result.current.formState.errors[Value_Key.USERNAME] + ).toBeUndefined(); + }); + }); }); diff --git a/src/hook/index.tsx b/src/hook/index.tsx index 26f241c..4f4b176 100644 --- a/src/hook/index.tsx +++ b/src/hook/index.tsx @@ -1,4 +1,4 @@ -import React from "react"; +import React, { useState } from "react"; import { SubmitFunction, useActionData, useSubmit } from "@remix-run/react"; import { SubmitErrorHandler, @@ -35,9 +35,11 @@ export const useRemixForm = ({ const submit = useSubmit(); const data = useActionData(); const methods = useForm(formProps); + const [formSubmitted, setFormSubmitted] = useState(false); // Submits the data to the server when form is valid const onSubmit = (data: T) => { + setFormSubmitted(true); submit(createFormData({ ...data, ...submitData }), { method: "post", ...submitConfig, @@ -62,7 +64,15 @@ export const useRemixForm = ({ isLoading, } = formState; - const formErrors = mergeErrors(errors, data?.errors ? data.errors : data); + const onMerge = () => { + setFormSubmitted(false); + }; + + // Will only merge data from useActionData if form was just submitted and only make + // formSubmitted false if data exist to useActionData to account for multiple renders + const formErrors = formSubmitted + ? mergeErrors(errors, data, onMerge) + : errors; return { ...methods, diff --git a/src/utilities/index.ts b/src/utilities/index.ts index a953156..433f647 100644 --- a/src/utilities/index.ts +++ b/src/utilities/index.ts @@ -169,9 +169,10 @@ The function recursively merges the objects and returns the resulting object. */ export const mergeErrors = ( frontendErrors: Partial>>, - backendErrors?: Partial>> + backendErrors?: Partial>>, + onMerge?: () => void ) => { - if (!backendErrors) { + if (!backendErrors || (onMerge && Object.keys(backendErrors).length === 0)) { return frontendErrors; } @@ -189,5 +190,7 @@ export const mergeErrors = ( } } + onMerge && onMerge(); + return frontendErrors; };