diff --git a/package-lock.json b/package-lock.json index 4731274..083cfba 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "remix-hook-form", - "version": "5.1.0", + "version": "5.3.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "remix-hook-form", - "version": "5.1.0", + "version": "5.3.0", "license": "MIT", "workspaces": [ "src/testing-app", @@ -42,7 +42,7 @@ "@remix-run/react": "^2.0.0", "react": "^18.2.0", "react-dom": "^18.2.0", - "react-hook-form": "^7.51.0" + "react-hook-form": "^7.55.0" } }, "node_modules/@ampproject/remapping": { @@ -16302,7 +16302,7 @@ "react": "^18.2.0", "react-dom": "^18.2.0", "react-hook-form": "7.48.2", - "remix-hook-form": "^5.0.0" + "remix-hook-form": "*" }, "devDependencies": { "@remix-run/dev": "^2.3.0", diff --git a/package.json b/package.json index bb85223..09f4928 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "remix-hook-form", - "version": "5.1.1", + "version": "5.3.0", "description": "Utility wrapper around react-hook-form for use with Remix.run", "type": "module", "main": "./dist/index.cjs", @@ -48,7 +48,7 @@ }, "repository": { "type": "git", - "url": "git+https://github.com/Code-Forge-Net/remix-hook-form.git" + "url": "git+https://github.com/forge-42/remix-hook-form.git" }, "keywords": [ "React", @@ -61,16 +61,16 @@ "author": "Alem Tuzlak", "license": "MIT", "bugs": { - "url": "https://github.com/Code-Forge-Net/remix-hook-form/issues" + "url": "https://github.com/forge-42/remix-hook-form/issues" }, - "homepage": "https://github.com/Code-Forge-Net/remix-hook-form#readme", + "homepage": "https://github.com/forge-42/remix-hook-form#readme", "peerDependencies": { "@remix-run/react": "^2.0.0", "react": "^18.2.0", "react-dom": "^18.2.0", - "react-hook-form": "^7.51.0" + "react-hook-form": "^7.55.0" }, - "readme": "https://github.com/Code-Forge-Net/remix-hook-form#readme", + "readme": "https://github.com/forge-42/remix-hook-form#readme", "devDependencies": { "@hookform/resolvers": "^3.1.0", "@remix-run/node": "^2.3.0", diff --git a/src/hook/index.test.tsx b/src/hook/index.test.tsx index 2942700..0bac466 100644 --- a/src/hook/index.test.tsx +++ b/src/hook/index.test.tsx @@ -16,9 +16,10 @@ const fetcherSubmitMock = vi.fn(); const useActionDataMock = vi.hoisted(() => vi.fn()); const useNavigationMock = vi.hoisted(() => - vi.fn<() => Pick>(() => ({ + vi.fn<() => Pick>(() => ({ state: "idle", formData: undefined, + json: undefined, })), ); @@ -249,12 +250,70 @@ describe("useRemixForm", () => { useNavigationMock.mockReturnValue({ state: "submitting", formData: new FormData(), + json: undefined, }); rerender(); expect(result.current.formState.isSubmitting).toBe(true); - useNavigationMock.mockReturnValue({ state: "idle", formData: undefined }); + useNavigationMock.mockReturnValue({ + state: "idle", + formData: undefined, + json: undefined, + }); + rerender(); + + expect(result.current.formState.isSubmitting).toBe(false); + }); + + it("should reset isSubmitting when the form is submitted using encType: application/json", async () => { + submitMock.mockReset(); + useNavigationMock.mockClear(); + + const { result, rerender } = renderHook(() => + useRemixForm({ + resolver: () => ({ values: {}, errors: {} }), + submitConfig: { + action: "/submit", + encType: "application/json", + }, + }), + ); + + expect(result.current.formState.isSubmitting).toBe(false); + + act(() => { + result.current.handleSubmit({} as any); + }); + expect(result.current.formState.isSubmitting).toBe(true); + + await waitFor(() => expect(submitMock).toHaveBeenCalledTimes(1)); + + expect(result.current.formState.isSubmitting).toBe(true); + + expect(submitMock).toHaveBeenCalledWith( + {}, + { + method: "post", + action: "/submit", + encType: "application/json", + }, + ); + + useNavigationMock.mockReturnValue({ + state: "submitting", + formData: undefined, + json: {}, + }); + rerender(); + + expect(result.current.formState.isSubmitting).toBe(true); + + useNavigationMock.mockReturnValue({ + state: "idle", + formData: undefined, + json: undefined, + }); rerender(); expect(result.current.formState.isSubmitting).toBe(false); diff --git a/src/hook/index.tsx b/src/hook/index.tsx index 33c118c..be364df 100644 --- a/src/hook/index.tsx +++ b/src/hook/index.tsx @@ -67,14 +67,24 @@ export const useRemixForm = ({ const methods = useForm({ ...formProps, errors: data?.errors }); const navigation = useNavigation(); // Either it's submitted to an action or submitted to a fetcher (or neither) - const isSubmittingForm = useMemo( - () => - Boolean( - (navigation.state !== "idle" && navigation.formData !== undefined) || - (fetcher?.state !== "idle" && fetcher?.formData !== undefined), - ), - [navigation.state, navigation.formData, fetcher?.state, fetcher?.formData], - ); + const isSubmittingForm = useMemo(() => { + const navigationIsSubmitting = + navigation.state !== "idle" && + (navigation.formData ?? navigation.json) !== undefined; + + const fetcherIsSubmitting = + fetcher?.state !== "idle" && + (fetcher?.formData ?? fetcher?.json) !== undefined; + + return navigationIsSubmitting || fetcherIsSubmitting; + }, [ + navigation.state, + navigation.formData, + navigation.json, + fetcher?.state, + fetcher?.formData, + fetcher?.json, + ]); // A state to keep track whether we're actually submitting the form through the network const [isSubmittingNetwork, setIsSubmittingNetwork] = useState(false); diff --git a/src/utilities/index.test.ts b/src/utilities/index.test.ts index c31d8bc..dca677c 100644 --- a/src/utilities/index.test.ts +++ b/src/utilities/index.test.ts @@ -138,7 +138,7 @@ describe("parseFormData", () => { mockFormData.append("formData", blob); requestFormDataSpy.mockResolvedValueOnce(mockFormData); const data = await parseFormData<{ formData: any }>(request); - expect(data.formData).toBeTypeOf("string"); + expect(data.formData).toBeTypeOf("object"); }); }); diff --git a/src/utilities/index.ts b/src/utilities/index.ts index c2ac6ac..58351e8 100644 --- a/src/utilities/index.ts +++ b/src/utilities/index.ts @@ -1,12 +1,15 @@ -import type { FieldValues, Resolver, FieldErrors } from "react-hook-form"; +import type { FieldErrors, FieldValues, Resolver } from "react-hook-form"; -const tryParseJSON = (jsonString: string) => { +const tryParseJSON = (value: string | File | Blob) => { + if (value instanceof File || value instanceof Blob) { + return value; + } try { - const json = JSON.parse(jsonString); + const json = JSON.parse(value); return json; } catch (e) { - return jsonString; + return value; } }; @@ -28,7 +31,7 @@ export const generateFormData = ( // Iterate through each key-value pair in the form data. for (const [key, value] of formData.entries()) { // Try to convert data to the original type, otherwise return the original value - const data = preserveStringified ? value : tryParseJSON(value.toString()); + const data = preserveStringified ? value : tryParseJSON(value); // Split the key into an array of parts. const keyParts = key.split("."); // Initialize a variable to point to the current object in the output object. @@ -166,9 +169,9 @@ export const createFormData = ( continue; } if ( - value instanceof Array && + Array.isArray(value) && value.length > 0 && - (value[0] instanceof File || value[0] instanceof Blob) + value.every((item) => item instanceof File || item instanceof Blob) ) { for (let i = 0; i < value.length; i++) { formData.append(key, value[i]); @@ -211,7 +214,7 @@ Or parses the specified FormData to retrieve the data @returns {Promise} - A promise that resolves to the data of type T. @throws {Error} - If no data is found for the specified key, or if the retrieved data is not a string. */ -export const parseFormData = async ( +export const parseFormData = async ( request: Request | FormData, preserveStringified = false, ): Promise => { diff --git a/test-apps/react-router/.react-router/types/app/+types/root.ts b/test-apps/react-router/.react-router/types/app/+types/root.ts new file mode 100644 index 0000000..62b3b78 --- /dev/null +++ b/test-apps/react-router/.react-router/types/app/+types/root.ts @@ -0,0 +1,40 @@ +// React Router generated types for route: +// root.tsx + +import type * as T from "react-router/route-module" + + + +type Module = typeof import("../root") + +export type Info = { + parents: [], + id: "root" + file: "root.tsx" + path: "" + params: {} + module: Module + loaderData: T.CreateLoaderData + actionData: T.CreateActionData +} + +export namespace Route { + export type LinkDescriptors = T.LinkDescriptors + export type LinksFunction = () => LinkDescriptors + + export type MetaArgs = T.CreateMetaArgs + export type MetaDescriptors = T.MetaDescriptors + export type MetaFunction = (args: MetaArgs) => MetaDescriptors + + export type HeadersArgs = T.HeadersArgs + export type HeadersFunction = (args: HeadersArgs) => Headers | HeadersInit + + export type LoaderArgs = T.CreateServerLoaderArgs + export type ClientLoaderArgs = T.CreateClientLoaderArgs + export type ActionArgs = T.CreateServerActionArgs + export type ClientActionArgs = T.CreateClientActionArgs + + export type HydrateFallbackProps = T.CreateHydrateFallbackProps + export type ComponentProps = T.CreateComponentProps + export type ErrorBoundaryProps = T.CreateErrorBoundaryProps +} \ No newline at end of file diff --git a/test-apps/react-router/.react-router/types/app/routes/+types/home.ts b/test-apps/react-router/.react-router/types/app/routes/+types/home.ts new file mode 100644 index 0000000..f17ae8e --- /dev/null +++ b/test-apps/react-router/.react-router/types/app/routes/+types/home.ts @@ -0,0 +1,40 @@ +// React Router generated types for route: +// routes/home.tsx + +import type * as T from "react-router/route-module" + +import type { Info as Parent0 } from "../../+types/root" + +type Module = typeof import("../home") + +export type Info = { + parents: [Parent0], + id: "routes/home" + file: "routes/home.tsx" + path: "undefined" + params: {} + module: Module + loaderData: T.CreateLoaderData + actionData: T.CreateActionData +} + +export namespace Route { + export type LinkDescriptors = T.LinkDescriptors + export type LinksFunction = () => LinkDescriptors + + export type MetaArgs = T.CreateMetaArgs + export type MetaDescriptors = T.MetaDescriptors + export type MetaFunction = (args: MetaArgs) => MetaDescriptors + + export type HeadersArgs = T.HeadersArgs + export type HeadersFunction = (args: HeadersArgs) => Headers | HeadersInit + + export type LoaderArgs = T.CreateServerLoaderArgs + export type ClientLoaderArgs = T.CreateClientLoaderArgs + export type ActionArgs = T.CreateServerActionArgs + export type ClientActionArgs = T.CreateClientActionArgs + + export type HydrateFallbackProps = T.CreateHydrateFallbackProps + export type ComponentProps = T.CreateComponentProps + export type ErrorBoundaryProps = T.CreateErrorBoundaryProps +} \ No newline at end of file