main 43bfbecae70d cached
38 files
109.8 KB
28.9k tokens
27 symbols
1 requests
Download .txt
Repository: Code-Forge-Net/remix-hook-form
Branch: main
Commit: 43bfbecae70d
Files: 38
Total size: 109.8 KB

Directory structure:
gitextract__hdis4zo/

├── .eslintrc
├── .github/
│   ├── FUNDING.yml
│   └── workflows/
│       ├── publish-commit.yaml
│       ├── publish.yaml
│       └── validate.yaml
├── .gitignore
├── .prettierignore
├── .prettierrc
├── CODE_OF_CONDUCT.md
├── LICENSE
├── README.md
├── SECURITY.MD
├── package.json
├── pull_request_template.md
├── src/
│   ├── hook/
│   │   ├── index.test.tsx
│   │   └── index.tsx
│   ├── index.ts
│   ├── middleware/
│   │   └── index.ts
│   └── utilities/
│       ├── index.test.ts
│       └── index.ts
├── test-apps/
│   └── react-router/
│       ├── .dockerignore
│       ├── .gitignore
│       ├── Dockerfile
│       ├── Dockerfile.bun
│       ├── Dockerfile.pnpm
│       ├── README.md
│       ├── app/
│       │   ├── app.css
│       │   ├── root.tsx
│       │   ├── routes/
│       │   │   └── home.tsx
│       │   ├── routes.ts
│       │   └── welcome/
│       │       └── welcome.tsx
│       ├── package.json
│       ├── react-router.config.ts
│       ├── tailwind.config.ts
│       ├── tsconfig.json
│       └── vite.config.ts
├── tsconfig.json
└── vitest.config.ts

================================================
FILE CONTENTS
================================================

================================================
FILE: .eslintrc
================================================
{
  "extends": [
    "eslint:recommended",
    "plugin:@typescript-eslint/recommended",
    "plugin:react/recommended",
    "prettier"
  ],
  "ignorePatterns": ["node_modules/", "testing-app"],
  "parser": "@typescript-eslint/parser",
  "parserOptions": {
    "ecmaVersion": "latest",
    "sourceType": "module"
  },
  "plugins": ["@typescript-eslint", "react", "prettier"],
  "rules": {
    "@typescript-eslint/no-unnecessary-type-constraint": "off",
    "@typescript-eslint/no-explicit-any": "off",
    "no-console": "error"
  },
  "settings": {
    "react": {
      "version": "18"
    }
  }
}


================================================
FILE: .github/FUNDING.yml
================================================
# These are supported funding model platforms

github: [AlemTuzlak]
patreon: # Replace with a single Patreon username
open_collective: # Replace with a single Open Collective username
ko_fi: # Replace with a single Ko-fi username
tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel
community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry
liberapay: # Replace with a single Liberapay username
issuehunt: # Replace with a single IssueHunt username
otechie: # Replace with a single Otechie username
lfx_crowdfunding: # Replace with a single LFX Crowdfunding project-name e.g., cloud-foundry
custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2']


================================================
FILE: .github/workflows/publish-commit.yaml
================================================
name: 🚀 pkg-pr-new
on: [push, pull_request]

jobs:
  build:
    runs-on: ubuntu-latest

    steps:
      - name: Checkout code
        uses: actions/checkout@v2

      - run: corepack enable
      - uses: actions/setup-node@v4
        with:
          node-version: 20
          cache: "npm"

      - name: Install dependencies
        run: npm install

      - name: Build
        run: npm run build

      - run: npx pkg-pr-new publish


================================================
FILE: .github/workflows/publish.yaml
================================================
name: Publish Package to npmjs
on:
  release:
    types: [published]
  workflow_dispatch:
jobs:
  npm-publish:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      # Setup .npmrc file to publish to npm
      - uses: actions/setup-node@v4
        with:
          node-version: "20.x"
          registry-url: "https://registry.npmjs.org"
      - run: npm ci
      - run: npm publish
        env:
          NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}

================================================
FILE: .github/workflows/validate.yaml
================================================
name: 🚀 Validation Pipeline
concurrency:
  group: ${{ github.repository }}-${{ github.workflow }}-${{ github.ref }}
  cancel-in-progress: true
on:
  push:
    branches: [main]
  pull_request:
    branches: [main]

permissions:
  actions: write
  contents: read
  # Required to put a comment into the pull-request
  pull-requests: write
jobs:
#  lint:
#    name: ⬣ Biome lint
#    runs-on: ubuntu-latest
#    steps:
#     - name: ⬇️ Checkout repo
#       uses: actions/checkout@v4
#     - name: Setup Biome
#       uses: biomejs/setup-biome@v2
#     - name: Run Biome
#       run: biome ci .

  typecheck:
    name: 🔎 Type check
    runs-on: ubuntu-latest
    steps:
      - name: 🛑 Cancel Previous Runs
        uses: styfle/cancel-workflow-action@0.12.1
      - name: ⬇️ Checkout repo
        uses: actions/checkout@v4
      - name: ⎔ Setup node
        uses: actions/setup-node@v4
        with:
          node-version: 20
      - name: 📥 Download deps
        uses: bahmutov/npm-install@v1
        with:
          useLockFile: false
      - name: 🔎 Type check
        run: npm run typecheck

  vitest:
    name: ⚡ Unit Tests
    runs-on: ubuntu-latest
    steps:
      - name: 🛑 Cancel Previous Runs
        uses: styfle/cancel-workflow-action@0.12.1
      - name: ⬇️ Checkout repo
        uses: actions/checkout@v4
      - name: ⎔ Setup node
        uses: actions/setup-node@v4
        with:
          node-version: 20
      - name: 📥 Download deps
        uses: bahmutov/npm-install@v1
        with:
          useLockFile: false
      - name: Install dotenv cli
        run: npm install -g dotenv-cli
      - name: ⚡ Run vitest
        run: npm run test
#      - name: "Report Coverage"
        # Only works if you set `reportOnFailure: true` in your vite config as specified above
#        if: always()
#        uses: davelosert/vitest-coverage-report-action@v2
 

================================================
FILE: .gitignore
================================================
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
lerna-debug.log*

# Diagnostic reports (https://nodejs.org/api/report.html)
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json

# Runtime data
pids
*.pid
*.seed
*.pid.lock

# Directory for instrumented libs generated by jscoverage/JSCover
lib-cov

# Coverage directory used by tools like istanbul
coverage
*.lcov

# nyc test coverage
.nyc_output

# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
.grunt

# Bower dependency directory (https://bower.io/)
bower_components

# node-waf configuration
.lock-wscript

# Compiled binary addons (https://nodejs.org/api/addons.html)
build/Release

# Dependency directories
node_modules/
jspm_packages/

# TypeScript v1 declaration files
typings/

# TypeScript cache
*.tsbuildinfo

# Optional npm cache directory
.npm

# Optional eslint cache
.eslintcache

# Microbundle cache
.rpt2_cache/
.rts2_cache_cjs/
.rts2_cache_es/
.rts2_cache_umd/

# Optional REPL history
.node_repl_history

# Output of 'npm pack'
*.tgz

# Yarn Integrity file
.yarn-integrity

# dotenv environment variables file
.env
.env.test

# parcel-bundler cache (https://parceljs.org/)
.cache

# Next.js build output
.next

# Nuxt.js build / generate output
.nuxt
dist

# Gatsby files
.cache/
# Comment in the public line in if your project uses Gatsby and *not* Next.js
# https://nextjs.org/blog/next-9-1#public-directory-support
# public

# vuepress build output
.vuepress/dist

# Serverless directories
.serverless/

# FuseBox cache
.fusebox/

# DynamoDB Local files
.dynamodb/

# TernJS port file
.tern-port

/build
.history
.react-router
.turbo


================================================
FILE: .prettierignore
================================================
node_modules
/build

================================================
FILE: .prettierrc
================================================
{
  
}

================================================
FILE: CODE_OF_CONDUCT.md
================================================
# Contributor Covenant Code of Conduct

## Our Pledge

We as members, contributors, and leaders pledge to make participation in our
community a harassment-free experience for everyone, regardless of age, body
size, visible or invisible disability, ethnicity, sex characteristics, gender
identity and expression, level of experience, education, socio-economic status,
nationality, personal appearance, race, religion, or sexual identity
and orientation.

We pledge to act and interact in ways that contribute to an open, welcoming,
diverse, inclusive, and healthy community.

## Our Standards

Examples of behavior that contributes to a positive environment for our
community include:

* Demonstrating empathy and kindness toward other people
* Being respectful of differing opinions, viewpoints, and experiences
* Giving and gracefully accepting constructive feedback
* Accepting responsibility and apologizing to those affected by our mistakes,
  and learning from the experience
* Focusing on what is best not just for us as individuals, but for the
  overall community

Examples of unacceptable behavior include:

* The use of sexualized language or imagery, and sexual attention or
  advances of any kind
* Trolling, insulting or derogatory comments, and personal or political attacks
* Public or private harassment
* Publishing others' private information, such as a physical or email
  address, without their explicit permission
* Other conduct which could reasonably be considered inappropriate in a
  professional setting

## Enforcement Responsibilities

Community leaders are responsible for clarifying and enforcing our standards of
acceptable behavior and will take appropriate and fair corrective action in
response to any behavior that they deem inappropriate, threatening, offensive,
or harmful.

Community leaders have the right and responsibility to remove, edit, or reject
comments, commits, code, wiki edits, issues, and other contributions that are
not aligned to this Code of Conduct, and will communicate reasons for moderation
decisions when appropriate.

## Scope

This Code of Conduct applies within all community spaces, and also applies when
an individual is officially representing the community in public spaces.
Examples of representing our community include using an official e-mail address,
posting via an official social media account, or acting as an appointed
representative at an online or offline event.

## Enforcement

Instances of abusive, harassing, or otherwise unacceptable behavior may be
reported to the community leaders responsible for enforcement at
.
All complaints will be reviewed and investigated promptly and fairly.

All community leaders are obligated to respect the privacy and security of the
reporter of any incident.

## Enforcement Guidelines

Community leaders will follow these Community Impact Guidelines in determining
the consequences for any action they deem in violation of this Code of Conduct:

### 1. Correction

**Community Impact**: Use of inappropriate language or other behavior deemed
unprofessional or unwelcome in the community.

**Consequence**: A private, written warning from community leaders, providing
clarity around the nature of the violation and an explanation of why the
behavior was inappropriate. A public apology may be requested.

### 2. Warning

**Community Impact**: A violation through a single incident or series
of actions.

**Consequence**: A warning with consequences for continued behavior. No
interaction with the people involved, including unsolicited interaction with
those enforcing the Code of Conduct, for a specified period of time. This
includes avoiding interactions in community spaces as well as external channels
like social media. Violating these terms may lead to a temporary or
permanent ban.

### 3. Temporary Ban

**Community Impact**: A serious violation of community standards, including
sustained inappropriate behavior.

**Consequence**: A temporary ban from any sort of interaction or public
communication with the community for a specified period of time. No public or
private interaction with the people involved, including unsolicited interaction
with those enforcing the Code of Conduct, is allowed during this period.
Violating these terms may lead to a permanent ban.

### 4. Permanent Ban

**Community Impact**: Demonstrating a pattern of violation of community
standards, including sustained inappropriate behavior,  harassment of an
individual, or aggression toward or disparagement of classes of individuals.

**Consequence**: A permanent ban from any sort of public interaction within
the community.

## Attribution

This Code of Conduct is adapted from the [Contributor Covenant][homepage],
version 2.0, available at
https://www.contributor-covenant.org/version/2/0/code_of_conduct.html.

Community Impact Guidelines were inspired by [Mozilla's code of conduct
enforcement ladder](https://github.com/mozilla/diversity).

[homepage]: https://www.contributor-covenant.org

For answers to common questions about this code of conduct, see the FAQ at
https://www.contributor-covenant.org/faq. Translations are available at
https://www.contributor-covenant.org/translations.

================================================
FILE: LICENSE
================================================
MIT License

Copyright (c) 2023 Code Forge

Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.


================================================
FILE: README.md
================================================
# remix-hook-form

![GitHub Repo stars](https://img.shields.io/github/stars/forge-42/remix-hook-form?style=social)
![npm](https://img.shields.io/npm/v/remix-hook-form?style=plastic)
![GitHub](https://img.shields.io/github/license/forge-42/remix-hook-form?style=plastic)
![npm](https://img.shields.io/npm/dy/remix-hook-form?style=plastic) 
![npm](https://img.shields.io/npm/dw/remix-hook-form?style=plastic) 
![GitHub top language](https://img.shields.io/github/languages/top/forge-42/remix-hook-form?style=plastic) 

Remix-hook-form is a powerful and lightweight wrapper around [react-hook-form](https://react-hook-form.com/) that streamlines the process of working with forms and form data in your [React Router](https://reactrouter.com) applications. With a comprehensive set of hooks and utilities, you'll be able to easily leverage the flexibility of react-hook-form without the headache of boilerplate code.

And the best part? Remix-hook-form has zero dependencies, making it easy to integrate into your existing projects and workflows. Say goodbye to bloated dependencies and hello to a cleaner, more efficient development process with Remix-hook-form. 

Oh, and did we mention that this is fully Progressively enhanced? That's right, you can use this with or without javascript!

## Remix.run support
Versions older than 6.0.0 are compatible with [Remix.run](https://remix.run) applications. If you are using Remix.run, please use version 5.1.1 or lower.

## Install

```bash
npm install remix-hook-form react-hook-form
```

## Basic usage

Here is an example usage of remix-hook-form. It will work with **and without** JS. Before running the example, ensure to install additional dependencies:

```bash
npm install zod @hookform/resolvers
```

```ts
import { useRemixForm, getValidatedFormData } from "remix-hook-form";
import { Form } from "react-router";
import { zodResolver } from "@hookform/resolvers/zod";
import * as zod from "zod";
import type { Route } from "./+types/home";


const schema = zod.object({
  name: zod.string().min(1),
  email: zod.string().email().min(1),
});

type FormData = zod.infer<typeof schema>;

const resolver = zodResolver(schema);

export const action = async ({ request }: Route.ActionArgs) => {
  const { errors, data, receivedValues: defaultValues } =
    await getValidatedFormData<FormData>(request, resolver);
  if (errors) {
    // The keys "errors" and "defaultValues" are picked up automatically by useRemixForm
    return { errors, defaultValues };
  }

  // Do something with the data
  return data;
};

export default function MyForm() {
  const {
    handleSubmit,
    formState: { errors },
    register,
  } = useRemixForm<FormData>({
    mode: "onSubmit",
    resolver,
  });

  return (
    <Form onSubmit={handleSubmit} method="POST">
      <label>
        Name:
        <input type="text" {...register("name")} />
        {errors.name && <p>{errors.name.message}</p>}
      </label>
      <label>
        Email:
        <input type="email" {...register("email")} />
        {errors.email && <p>{errors.email.message}</p>}
      </label>
      <button type="submit">Submit</button>
    </Form>
  );
}
```

## Serialization of values client => server

By default, all values are serialized to strings before being sent to the server. This is because that is how form data works, it only accepts strings, nulls or files, this means that even strings would get "double stringified" and become strings like this:
```ts
const string = "'123'";
```
This helps with the fact that validation on the server can't know if your stringified values received from the client are actually strings or numbers or dates or whatever.

For example, if you send this formData to the server:

```ts
const formData = {
  name: "123",
  age: 30,
  hobbies: ["Reading", "Writing", "Coding"],
  boolean: true,
  a: null,
  // this gets omitted because it's undefined
  b: undefined,
  numbers: [1, 2, 3],
  other: {
    skills: ["testing", "testing"],
    something: "else",
  },
};
```

It would be sent to the server as:
```ts
{
  name: "123",
  age: "30",
  hobbies: "[\"Reading\",\"Writing\",\"Coding\"]",
  boolean: "true",
  a: "null",
  numbers: "[1,2,3]",
  other: "{\"skills\":[\"testing\",\"testing\"],\"something\":\"else\"}",
}
```

Then the server does not know if the `name` property used to be a string or a number, your validation schema would fail if it parsed it back to a number and you expected it to be a string. Conversely, if you didn't parse the rest of this data you wouldn't have objects,
arrays etc. but strings. 

The double stringification helps with this as it would correctly parse the data back to the original types, but it also means that you have to use the helpers provided by this package to parse the data back to the original types.



This is the default behavior, but you can change this behavior by setting the `stringifyAllValues` prop to `false` in the `useRemixForm` hook.

```ts
const { handleSubmit, formState, register } = useRemixForm({
  mode: "onSubmit",
  resolver,
  stringifyAllValues: false,
});
```

This only affects strings really as it either double stringifies them or it doesn't. The bigger impact of all of this is on the server side.

By default all the server helpers expect the data to be double stringified which allows the utils to parse the data back to the original types easily. If you don't want to double stringify the data then you can set the `preserveStringified` prop to `true` in the `getValidatedFormData` function.

```ts
// Third argument is preserveStringified and is false by default
const { errors, data } = await getValidatedFormData(request, resolver, true);
```
Because the data by default is double stringified the data returned by the util and sent to your validator would look like this:

```ts
const data = {
  name: "123",
  age: 30,
  hobbies: ["Reading", "Writing", "Coding"],
  boolean: true,
  a: null,
  // this gets omitted because it's undefined
  b: undefined,
  numbers: [1, 2, 3],
  other: {
    skills: ["testing", "testing"],
    something: "else",
  },
};
```

If you set `preserveStringified` to `true` then the data would look like this:

```ts
const data = {
  name: "123",
  age: "30",
  hobbies: ["Reading", "Writing", "Coding"],
  boolean: "true",
  a: "null",
  numbers: ["1","2","3"],
  other: {
    skills: ["testing", "testing"],
    something: "else",
  },
};

```

This means that your validator would have to handle all the type conversions and validations for all the different types of data. This is a lot of work and it's not worth it usually, the best place to use this approach if you store the info in searchParams. If you want to handle it like this what you can do is use something like `coerce` from `zod` to convert the data to the correct type before checking it.

```ts
import { z } from "zod";

const formDataZodSchema = z.object({
  name: z.string().min(1),
  // converts the string to a number
  age: z.coerce.number().int().positive(), 
});

type SchemaFormData = z.infer<typeof formDataZodSchema>;

const resolver = zodResolver(formDataZodSchema);

export const action = async ({ request }: Route.ActionArgs) => {
  const { errors, data } = await getValidatedFormData<SchemaFormData>(
    request,
    resolver,
    true,
  );
  if (errors) {
    return { errors };
  }
  // Do something with the data
};
```

 

## Fetcher usage

You can pass in a fetcher as an optional prop and `useRemixForm` will use that fetcher to submit the data and read the errors instead of the default behavior. For more info see the docs on `useRemixForm` below.


## Video example and tutorial

If you wish to learn in depth on how form handling works in React router/Remix.run and want an example using this package I have prepared a video tutorial on how to do it. It's a bit long but it covers everything 
you need to know about form handling in React Router/Remix. It also covers how to use this package. You can find it here:

https://youtu.be/iom5nnj29sY?si=l52WRE2bqpkS2QUh


## Middleware mode

From v7 you can use middleware to extract the form data and access it anywhere in your actions and loaders.  
All you have to do is set it up in your `root.tsx` file like this:

```ts
import { unstable_extractFormDataMiddleware } from "remix-hook-form/middleware";

export const unstable_middleware = [unstable_extractFormDataMiddleware()];
```


And then access it in your actions and loaders like this:


```ts
import { getFormData, getValidatedFormData } from "remix-hook-form/middleware";

export const loader = async ({ context }: LoaderFunctionArgs) => {
  const searchParamsFormData = await getFormData(context); 
  return { result: "success" };
};
export const action = async ({ context }: ActionFunctionArgs) => {
  // OR:   const formData = await getFormData(context); 
  const { data, errors, receivedValues } = await getValidatedFormData<FormData>(
    context,
    resolver,
  );
  if (errors) {
    return { errors, receivedValues };
  } 
  return { result: "success" };
};
```

## API's

### getValidatedFormData

Now supports no-js form submissions!

If you made a GET request instead of a POST request and you are using this inside of a loader it will try to extract the data from the search params

If the form is submitted without js it will try to parse the formData object and covert it to the same format as the data object returned by `useRemixForm`. If the form is submitted with js it will automatically extract the data from the request object and validate it.

getValidatedFormData is a utility function that can be used to validate form data in your action. It takes two arguments: the request/formData object and the resolver function. It returns an object with three properties: `errors`, `receivedValues` and `data`. If there are no errors, `errors` will be `undefined`. If there are errors, `errors` will be an object with the same shape as the `errors` object returned by `useRemixForm`. If there are no errors, `data` will be an object with the same shape as the `data` object returned by `useRemixForm`.

The `receivedValues` property allows you to set the default values of your form to the values that were received from the request object. This is useful if you want to display the form again with the values that were submitted by the user when there is no JS present
 
 #### Example with errors only
 If you don't want the form to persist submitted values in the case of validation errors then you can just return the `errors` object directly from the action.
```jsx
/** all the same code from above */

export const action = async ({ request }: Route.ActionArgs) => {
  // Takes the request from the frontend, parses and validates it and returns the data
  const { errors, data } =
    await getValidatedFormData<FormData>(request, resolver);
  if (errors) {
    return { errors };
  }
  // Do something with the data
};
```

#### Example with errors and receivedValues
If your action returrns `defaultValues` key then it will be automatically used by `useRemixForm` to populate the default values of the form.
```jsx
/** all the same code from above */

export const action = async ({ request }: Route.ActionArgs) => {
  // Takes the request from the frontend, parses and validates it and returns the data
  const { errors, data, receivedValues: defaultValues } =
    await getValidatedFormData<FormData>(request, resolver);
  if (errors) {
    return { errors, defaultValues };
  }
  // Do something with the data
};

```

### validateFormData

validateFormData is a utility function that can be used to validate form data in your action. It takes two arguments: the formData object and the resolver function. It returns an object with two properties: `errors` and `data`. If there are no errors, `errors` will be `undefined`. If there are errors, `errors` will be an object with the same shape as the `errors` object returned by `useRemixForm`. If there are no errors, `data` will be an object with the same shape as the `data` object returned by `useRemixForm`.

The difference between `validateFormData` and `getValidatedFormData` is that `validateFormData` only validates the data while the `getValidatedFormData` function also extracts the data automatically from the request object assuming you were using the default setup.

```jsx
/** all the same code from above */

export const action = async ({ request }: Route.ActionArgs) => {
  // Lets assume you get the data in a different way here but still want to validate it
  const formData = await yourWayOfGettingFormData(request);
  // Takes the request from the frontend, parses and validates it and returns the data
  const { errors, data } =
    await validateFormData<FormData>(formData, resolver);
  if (errors) {
    return { errors };
  }
  // Do something with the data
};

```

### createFormData

createFormData is a utility function that can be used to create a FormData object from the data returned by the handleSubmit function from `react-hook-form`. It takes one argument, the `data` from the `handleSubmit` function and it converts everything it can to strings and appends files as well. It returns a FormData object.

```jsx
/** all the same code from above */

export default function MyForm() {
  const { ... } = useRemixForm({
    ...,
    submitHandlers: {
      onValid: data => {
        // This will create a FormData instance ready to be sent to the server, by default all your data is converted to a string before sent
        const formData = createFormData(data); 
        // Do something with the formData
      }
    }
  });

  return (
   ...
  );
}

```

### parseFormData

parseFormData is a utility function that can be used to parse the data submitted to the action by the handleSubmit function from `react-hook-form`. It takes two arguments, first one is the `request` submitted from the frontend and the second one is `preserveStringified`, the form data you submit will be cast to strings because that is how form data works, when retrieving it you can either keep everything as strings or let the helper try to parse it back to original types (eg number to string), default is `false`. It returns an object that contains unvalidated `data` submitted from the frontend.


```jsx
/** all the same code from above */

export const action = async ({ request }: Route.ActionArgs) => {
  // Allows you to get the data from the request object without having to validate it
  const formData = await parseFormData(request);
  // formData.age will be a number
  const formDataStringified = await parseFormData(request, true);
  // formDataStringified.age will be a string
  // Do something with the data
};

```


### getFormDataFromSearchParams

If you're using a GET request formData is not available on the request so you can use this method to extract your formData from the search parameters assuming you set all your data in the search parameters

## Hooks

### useRemixForm

`useRemixForm` is a hook that can be used to create a form in your React Router / Remix application. It's basically the same as react-hook-form's [`useForm`](https://www.react-hook-form.com/api/useform/) hook, with the following differences:

**Additional options**
- `submitHandlers`: an object containing two properties:
  - `onValid`: can be passed into the function to override the default behavior of the `handleSubmit` success case provided by the hook. If you need to pass additional data not tracked in the form, you'll need to manually merge it here with your form data to be submitted, as the `submitData` hook option is ignored in this case.
  - `onInvalid`: can be passed into the function to override the default behavior of the `handleSubmit` error case provided by the hook.
- `submitConfig`: allows you to pass additional configuration to the `useSubmit` function from React Router / Remix, such as `{ replace: true }` to replace the current history entry instead of pushing a new one. The `submitConfig` trumps `Form` props from React Router / Remix. The following props will be used from `Form` if no submitConfig is provided:
  - `method`
  - `action`
  - `encType`
- `submitData`: allows you to pass additional data to the backend when the form is submitted.
- `fetcher`: if provided then this fetcher will be used to submit data and get a response (errors / defaultValues) instead of React Router/Remix's `useSubmit` and `useActionData` hooks.

**`register` will respect default values returned from the action**

If the React Router/Remix hook `useActionData` returns an object with `defaultValues` these will automatically be used as the default value when calling the `register` function. This is useful when the form has errors and you want to persist the values when JS is not enabled. If a `fetcher` is provided default values will be read from the fetcher's data.

**`handleSubmit`**

The returned `handleSubmit` function does two additional things
- The success case is provided by default where when the form is validated by the provided resolver, and it has no errors, it will automatically submit the form to the current route using a POST request. The data will be sent as `formData` to the action function.
- The data that is sent is automatically wrapped into a formData object and passed to the server ready to be used. Easiest way to consume it is by using the `parseFormData` or `getValidatedFormData` function from the `remix-hook-form` package.

**`formState.errors`**

The `errors` object inside `formState` is automatically populated with the errors returned by the action. If the action returns an `errors` key in it's data then that value will be used to populate errors, otherwise the whole action response is assumed to be the errors object. If a `fetcher` is provided then errors are read from the fetcher's data.

#### Examples

**Overriding the default onValid and onInvalid cases**

```jsx
  const { ... } = useRemixForm({
    ...ALL_THE_SAME_CONFIG_AS_REACT_HOOK_FORM,
    submitHandlers: {
      onValid: data => { 
        // Do something with the formData
      },
      onInvalid: errors => {
        // Do something with the errors
      }
    }
  });

```

**Overriding the submit from remix to do something else**

```jsx
  const { ... } = useRemixForm({
    ...ALL_THE_SAME_CONFIG_AS_REACT_HOOK_FORM,
    submitConfig: {
      replace: true,
      method: "PUT",
      action: "/api/youraction",
    },
  });

```

**Passing additional data to the backend**

```jsx
  const { ... } = useRemixForm({
    ...ALL_THE_SAME_CONFIG_AS_REACT_HOOK_FORM,
    submitData: {
      someFieldsOutsideTheForm: "someValue"
    },
  });

  // or if customizing with `onValid` handler
  const { ... } = useRemixForm({
    ...ALL_THE_SAME_CONFIG_AS_REACT_HOOK_FORM,
    submitHandlers: {
      onValid: data => { 
        const mergedData = { ...data, someFieldsOutsideTheForm: "someValue" }
        const formData = createFormData(mergedData);
        // ... submit
      }
    }
  });
```

### RemixFormProvider

Identical to the [`FormProvider`](https://react-hook-form.com/api/formprovider/) from `react-hook-form`, but it also returns the changed `formState.errors` and `handleSubmit` object.
```jsx
export default function Form() {
  const methods = useRemixForm();
 
  return (
    <RemixFormProvider {...methods} > // pass all methods into the context
      <form onSubmit={methods.handleSubmit}>
        <button type="submit" />
      </form>
    </RemixFormProvider>
  );
}

```

### useRemixFormContext

Exactly the same as [`useFormContext`](https://react-hook-form.com/api/useformcontext/) from `react-hook-form` but it also returns the changed `formState.errors` and `handleSubmit` object.

```jsx
export default function Form() {
  const methods = useRemixForm();
 

  return (
    <RemixFormProvider {...methods} > // pass all methods into the context
      <form onSubmit={methods.handleSubmit}>
        <NestedInput />
        <button type="submit" />
      </form>
    </RemixFormProvider>
  );
}

const NestedInput = () => {
  const { register } = useRemixFormContext(); // retrieve all hook methods
  return <input {...register("test")} />;
}

```


## Support 

If you like the project, please consider supporting us by giving a ⭐️ on Github.
## License

MIT

## Bugs

If you find a bug, please file an issue on [our issue tracker on GitHub](https://github.com/Code-Forge-Net/remix-hook-form/issues)


## Contributing

Thank you for considering contributing to Remix-hook-form! We welcome any contributions, big or small, including bug reports, feature requests, documentation improvements, or code changes.

To get started, please fork this repository and make your changes in a new branch. Once you're ready to submit your changes, please open a pull request with a clear description of your changes and any related issues or pull requests.

Please note that all contributions are subject to our [Code of Conduct](https://github.com/Code-Forge-Net/remix-hook-form/blob/main/CODE_OF_CONDUCT.md). By participating, you are expected to uphold this code.

We appreciate your time and effort in contributing to Remix-hook-form and helping to make it a better tool for the community!



================================================
FILE: SECURITY.MD
================================================
# Security Policy

## Supported Versions

| Version | Supported          |
| ------- | ------------------ | 
| 1.0.x   | :white_check_mark: |

## Reporting a Vulnerability

In case of a vulnerability please reach out to active maintainers of the project and report it to them. 

================================================
FILE: package.json
================================================
{
  "name": "remix-hook-form",
  "version": "7.1.1",
  "description": "Utility wrapper around react-hook-form for use with react-router v7+",
  "type": "module",
  "main": "./dist/index.cjs",
  "module": "./dist/index.js",
  "exports": {
    ".": {
      "types": "./dist/index.d.ts",
      "import": {
        "types": "./dist/index.d.ts",
        "import": "./dist/index.js",
        "default": "./dist/index.js"
      },
      "require": {
        "types": "./dist/index.d.cts",
        "import": "./dist/index.cjs",
        "require": "./dist/index.cjs"
      }
    },
    "./middleware": {
      "types": "./dist/middleware/index.d.ts",
      "import": {
        "types": "./dist/middleware/index.d.ts",
        "import": "./dist/middleware/index.js",
        "default": "./dist/middleware/index.js"
      },
      "require": {
        "types": "./dist/middleware/index.d.cts",
        "import": "./dist/middleware/index.cjs",
        "require": "./dist/middleware/index.cjs"
      }
    }
  },
  "typings": "./dist/index.d.ts",
  "files": [
    "dist"
  ],
  "workspaces": [
    ".",
    "test-apps/*"
  ],
  "scripts": {
    "build": "tsup src/index.ts src/middleware/index.ts --format cjs,esm --dts --clean",
    "react-router-dev": "npm run dev -w test-apps/react-router",
    "build:dev": "tsup src/index.ts src/middleware/index.ts --format cjs,esm --dts",
    "build:dev:watch": "npm run build:dev -- --watch",
    "dev": "npm-run-all -s build:dev -p react-router-dev build:dev:watch",
    "vite": "npm run build --watch -m development",
    "prepublishOnly": "npm run build",
    "test": "vitest run",
    "typecheck": "tsc",
    "validate": "npm run lint && npm run tsc && npm run test",
    "lint": "eslint \"src/**/*.+(ts|tsx)\"",
    "lint:fix": "npm run lint -- --fix",
    "prettier": "prettier src --check",
    "prettier:fix": "prettier src --write",
    "format-code": "npm run prettier:fix & npm run lint:fix"
  },
  "repository": {
    "type": "git",
    "url": "git+https://github.com/forge-42/remix-hook-form.git"
  },
  "keywords": [
    "React",
    "react-router",
    "react-router v7",
    "react-hook-form",
    "hooks",
    "remix",
    "remix react-hook-form",
    "react-router react-hook-form",
    "forms"
  ],
  "author": "Alem Tuzlak",
  "license": "MIT",
  "bugs": {
    "url": "https://github.com/forge-42/remix-hook-form/issues"
  },
  "homepage": "https://github.com/forge-42/remix-hook-form#readme",
  "peerDependencies": {
    "react": "^18.2.0 || ^19.0.0",
    "react-dom": "^18.2.0 || ^19.0.0",
    "react-hook-form": "^7.55.0",
    "react-router": ">=7.5.0"
  },
  "readme": "https://github.com/forge42dev/remix-hook-form#readme",
  "devDependencies": {
    "@hookform/resolvers": "^3.1.0",
    "@testing-library/react": "^14.0.0",
    "@types/node": "^18.15.11",
    "@types/react": "^18.0.34",
    "@vitest/coverage-v8": "^2.0.3",
    "babel-eslint": "^10.1.0",
    "eslint-config-prettier": "^9.0.0",
    "eslint-plugin-prettier": "^5.0.0",
    "happy-dom": "^9.5.0",
    "npm-run-all": "^4.1.5",
    "prettier": "^3.0.1",
    "react": "^18.2.0",
    "react-dom": "^18.2.0",
    "react-hook-form": "^7.51.0",
    "rollup": "^3.20.2",
    "rollup-plugin-typescript2": "^0.34.1",
    "tsup": "^8.3.5",
    "typescript": "^5.0.4",
    "vitest": "^2.0.3",
    "zod": "^3.21.4"
  }
}

================================================
FILE: pull_request_template.md
================================================
# Description

Please include a summary of the change and which issue is fixed. Please also include relevant motivation and context. List any dependencies that are required for this change.

Fixes # (issue)

If this is a new feature please add a description of what was added and why below:


## Type of change

Please delete options that are not relevant.

- [ ] Bug fix (non-breaking change which fixes an issue)
- [ ] New feature (non-breaking change which adds functionality)
- [ ] Breaking change (fix or feature that would cause existing functionality to not work as expected)
- [ ] This change requires a documentation update

# How Has This Been Tested?

Please describe the tests that you ran to verify your changes. Provide instructions so we can reproduce. Please also list any relevant details for your test configuration

- [ ] Unit tests

# Checklist:

- [ ] My code follows the guidelines of this project
- [ ] I have performed a self-review of my own code
- [ ] I have commented my code, particularly in hard-to-understand areas
- [ ] I have made corresponding changes to the documentation
- [ ] My changes generate no new warnings or errors
- [ ] I have added tests that prove my fix is effective or that my feature works
- [ ] New and existing unit tests pass locally with my changes
- [ ] Any dependent changes have been merged and published in downstream modules

================================================
FILE: src/hook/index.test.tsx
================================================
import {
  act,
  cleanup,
  fireEvent,
  render,
  renderHook,
  waitFor,
} from "@testing-library/react";
import React from "react";
import { type Navigation, useFetcher } from "react-router";
import { RemixFormProvider, useRemixForm, useRemixFormContext } from "./index";

const submitMock = vi.fn();
const fetcherSubmitMock = vi.fn();

const useActionDataMock = vi.hoisted(() => vi.fn());

const useNavigationMock = vi.hoisted(() =>
  vi.fn<() => Pick<Navigation, "state" | "formData" | "json">>(() => ({
    state: "idle",
    formData: undefined,
    json: undefined,
  })),
);

const useHrefMock = vi.hoisted(() => vi.fn());

vi.mock("react-router", () => ({
  useSubmit: () => submitMock,
  useActionData: useActionDataMock,
  useFetcher: () => ({ submit: fetcherSubmitMock, data: {} }),
  useNavigation: useNavigationMock,
  useHref: useHrefMock,
}));

describe("useRemixForm", () => {
  it("should return all the same output that react-hook-form returns", () => {
    const { result } = renderHook(() => useRemixForm({}));
    expect(result.current.register).toBeInstanceOf(Function);
    expect(result.current.unregister).toBeInstanceOf(Function);
    expect(result.current.setValue).toBeInstanceOf(Function);
    expect(result.current.getValues).toBeInstanceOf(Function);
    expect(result.current.trigger).toBeInstanceOf(Function);
    expect(result.current.reset).toBeInstanceOf(Function);
    expect(result.current.clearErrors).toBeInstanceOf(Function);
    expect(result.current.setError).toBeInstanceOf(Function);
    expect(result.current.formState).toEqual({
      disabled: false,
      dirtyFields: {},
      isDirty: false,
      isSubmitSuccessful: false,
      isSubmitted: false,
      isSubmitting: false,
      isValid: false,
      isValidating: false,
      validatingFields: {},
      touchedFields: {},
      submitCount: 0,
      isLoading: false,
      errors: {},
    });
    expect(result.current.handleSubmit).toBeInstanceOf(Function);
  });

  it("should call onSubmit function when the form is valid", async () => {
    const onValid = vi.fn();
    const onInvalid = vi.fn();

    const { result } = renderHook(() =>
      useRemixForm({
        resolver: () => ({ values: {}, errors: {} }),
        submitHandlers: {
          onValid,
          onInvalid,
        },
      }),
    );

    act(() => {
      // biome-ignore lint/suspicious/noExplicitAny: <explanation>
      result.current.handleSubmit({} as any);
    });
    await waitFor(() => {
      expect(onValid).toHaveBeenCalled();
    });
  });

  it("should reset isSubmitSuccessful after submission if reset is called", async () => {
    const { result } = renderHook(() =>
      useRemixForm({
        resolver: () => ({ values: {}, errors: {} }),
      }),
    );

    act(() => {
      // biome-ignore lint/suspicious/noExplicitAny: <explanation>
      result.current.handleSubmit({} as any);
    });
    await waitFor(() => {
      expect(result.current.formState.isSubmitSuccessful).toBe(true);
    });
    act(() => {
      result.current.reset();
    });
    expect(result.current.formState.isSubmitSuccessful).toBe(false);
  });

  it("should submit the form data to the server when the form is valid", async () => {
    const { result } = renderHook(() =>
      useRemixForm({
        resolver: () => ({ values: {}, errors: {} }),
        submitConfig: {
          action: "/submit",
        },
      }),
    );

    act(() => {
      // biome-ignore lint/suspicious/noExplicitAny: <explanation>
      result.current.handleSubmit({} as any);
    });
    await waitFor(() => {
      expect(submitMock).toHaveBeenCalledWith(expect.any(FormData), {
        method: "post",
        action: "/submit",
      });
    });
  });

  it("should submit the form data to the server using a fetcher when the form is valid", async () => {
    const {
      result: { current: fetcher },
    } = renderHook(() => useFetcher());
    const { result } = renderHook(() =>
      useRemixForm({
        fetcher,
        resolver: () => ({ values: {}, errors: {} }),
        submitConfig: {
          action: "/submit",
        },
      }),
    );

    act(() => {
      // biome-ignore lint/suspicious/noExplicitAny: <explanation>
      result.current.handleSubmit({} as any);
    });
    await waitFor(() => {
      expect(fetcherSubmitMock).toHaveBeenCalledWith(expect.any(FormData), {
        method: "post",
        action: "/submit",
      });
    });
  });

  it("should remove origin and basename from the action", async () => {
    submitMock.mockReset();
    useHrefMock.mockImplementation((to) => {
      if (to === "/") {
        return "/my-basename";
      }
    });
    vi.spyOn(window, "location", "get").mockReturnValueOnce({
      origin: "http://example.com",
    } as any);

    const { result } = renderHook(() =>
      useRemixForm({
        resolver: () => ({ values: {}, errors: {} }),
      }),
    );

    act(() => {
      // biome-ignore lint/suspicious/noExplicitAny: <explanation>
      result.current.handleSubmit({
        currentTarget: {
          action: "http://example.com/my-basename/basename-test-submit",
        },
      } as any);
    });
    await waitFor(() => {
      expect(submitMock).toHaveBeenCalledWith(expect.any(FormData), {
        method: "post",
        action: "/basename-test-submit",
      });
    });
  });

  it("should not re-render on validation if isValidating is not being accessed", async () => {
    const renderHookWithCount = () => {
      let count = 0;
      const renderCount = () => count;
      const result = renderHook(() => {
        count++;
        return useRemixForm({
          mode: "onChange",
          resolver: () => ({
            values: {
              name: "",
            },
            errors: {},
          }),
        });
      });
      return { renderCount, ...result };
    };

    const { result, renderCount } = renderHookWithCount();

    await act(async () => {
      result.current.setValue("name", "John", { shouldValidate: true });
      await new Promise((resolve) => setTimeout(resolve, 0));
    });

    await act(async () => {
      result.current.setValue("name", "Bob", { shouldValidate: true });
      await new Promise((resolve) => setTimeout(resolve, 0));
    });

    expect(renderCount()).toBe(1);
  });

  it("should re-render on validation if isValidating is being accessed", async () => {
    const renderHookWithCount = () => {
      let count = 0;
      const renderCount = () => count;
      const result = renderHook(() => {
        count++;
        return useRemixForm({
          mode: "onChange",
          resolver: () => ({
            values: {
              name: "",
            },
            errors: {},
          }),
        });
      });
      return { renderCount, ...result };
    };

    const { result, renderCount } = renderHookWithCount();

    // Accessing isValidating
    // eslint-disable-next-line @typescript-eslint/no-unused-vars
    const isValidating = result.current.formState.isValidating;

    await act(async () => {
      result.current.setValue("name", "John", { shouldValidate: true });
      await new Promise((resolve) => setTimeout(resolve, 0));
    });

    await act(async () => {
      result.current.setValue("name", "Bob", { shouldValidate: true });
      await new Promise((resolve) => setTimeout(resolve, 0));
    });

    expect(renderCount()).toBe(3);
  });

  it("should not flash incorrect isSubmitting status", async () => {
    submitMock.mockReset();
    useNavigationMock.mockClear();

    const { result, rerender } = renderHook(() =>
      useRemixForm({
        resolver: () => ({ values: {}, errors: {} }),
        submitConfig: {
          action: "/submit",
        },
      }),
    );

    expect(result.current.formState.isSubmitting).toBe(false);

    act(() => {
      // biome-ignore lint/suspicious/noExplicitAny: <explanation>
      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(expect.any(FormData), {
      method: "post",
      action: "/submit",
    });

    useNavigationMock.mockReturnValue({
      state: "submitting",
      formData: new FormData(),
      json: undefined,
    });
    rerender();

    expect(result.current.formState.isSubmitting).toBe(true);

    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);
  });

  it("should return defaultValue from the register function", async () => {
    const { result, rerender } = renderHook(() =>
      useRemixForm({
        resolver: () => ({
          values: { name: "", address: { street: "" } },
          errors: {},
        }),
        defaultValues: {
          name: "Default name",
          address: {
            street: "Default street",
          },
        },
      }),
    );

    let nameFieldProps = result.current.register("name");
    let streetFieldProps = result.current.register("address.street");

    expect(nameFieldProps.defaultValue).toBe("Default name");
    expect(nameFieldProps.defaultChecked).toBe(undefined);
    expect(streetFieldProps.defaultValue).toBe("Default street");
    expect(streetFieldProps.defaultChecked).toBe(undefined);

    useActionDataMock.mockReturnValue({
      defaultValues: {
        name: "Updated name",
        address: {
          street: "Updated street",
        },
      },
      errors: { name: "Enter another name" },
    });

    rerender();

    nameFieldProps = result.current.register("name");
    streetFieldProps = result.current.register("address.street");

    expect(nameFieldProps.defaultValue).toBe("Updated name");
    expect(nameFieldProps.defaultChecked).toBe(undefined);
    expect(streetFieldProps.defaultValue).toBe("Updated street");
    expect(streetFieldProps.defaultChecked).toBe(undefined);
  });

  it("should return defaultChecked from the register function when a boolean", async () => {
    const { result, rerender } = renderHook(() =>
      useRemixForm({
        resolver: () => ({
          values: { name: "", address: { street: "" }, boolean: true },
          errors: {},
        }),
        defaultValues: {
          name: "Default name",
          boolean: true,
          address: {
            street: "Default street",
          },
        },
      }),
    );

    let booleanFieldProps = result.current.register("boolean");

    expect(booleanFieldProps.defaultChecked).toBe(true);
    expect(booleanFieldProps.defaultValue).toBe(undefined);

    useActionDataMock.mockReturnValue({
      defaultValues: {
        name: "Updated name",
        address: {
          street: "Updated street",
        },
        boolean: false,
      },
      errors: { name: "Enter another name" },
    });

    rerender();

    booleanFieldProps = result.current.register("boolean");
    expect(booleanFieldProps.defaultChecked).toBe(false);
    expect(booleanFieldProps.defaultValue).toBe(undefined);
  });
});

afterEach(cleanup);

describe("RemixFormProvider", () => {
  it("should allow the user to submit via the useRemixForm handleSubmit using the context", () => {
    const { result } = renderHook(() =>
      useRemixForm({
        resolver: () => ({ values: {}, errors: {} }),
        submitConfig: {
          action: "/submit",
        },
      }),
    );
    const spy = vi.spyOn(result.current, "handleSubmit");

    const TestComponent = () => {
      const { handleSubmit } = useRemixFormContext();
      return <form onSubmit={handleSubmit} data-testid="test" />;
    };

    const { getByTestId } = render(
      <RemixFormProvider {...result.current}>
        <TestComponent />
      </RemixFormProvider>,
    );

    const form = getByTestId("test") as HTMLFormElement;
    fireEvent.submit(form);

    expect(spy).toHaveBeenCalled();
  });
});


================================================
FILE: src/hook/index.tsx
================================================
import React, {
  type FormEvent,
  type ReactNode,
  useEffect,
  useMemo,
  useState,
} from "react";
import {
  type DefaultValues,
  type FieldValues,
  FormProvider,
  type FormState,
  type KeepStateOptions,
  type Path,
  type RegisterOptions,
  type SubmitErrorHandler,
  type SubmitHandler,
  type UseFormHandleSubmit,
  type UseFormProps,
  type UseFormReturn,
  get,
  useForm,
  useFormContext,
} from "react-hook-form";
import {
  type FetcherWithComponents,
  type FormEncType,
  type FormMethod,
  type SubmitFunction,
  useActionData,
  useHref,
  useNavigation,
  useSubmit,
} from "react-router";

import { createFormData } from "../utilities";

export type SubmitFunctionOptions = Parameters<SubmitFunction>[1];

export interface UseRemixFormOptions<
  TFieldValues extends FieldValues,
  // biome-ignore lint/suspicious/noExplicitAny: defaults to any type
  TContext = any,
  TTransformedValues = TFieldValues,
> extends UseFormProps<TFieldValues, TContext, TTransformedValues> {
  submitHandlers?: {
    onValid?: SubmitHandler<TTransformedValues>;
    onInvalid?: SubmitErrorHandler<TFieldValues>;
  };
  submitConfig?: SubmitFunctionOptions;
  submitData?: FieldValues;
  fetcher?: FetcherWithComponents<unknown>;
  /**
   * If true, all values will be stringified before being sent to the server, otherwise everything but strings will be stringified (default: true)
   */
  stringifyAllValues?: boolean;
}
export const useRemixForm = <
  TFieldValues extends FieldValues,
  // biome-ignore lint/suspicious/noExplicitAny: defaults to any type
  TContext = any,
  TTransformedValues = TFieldValues,
>({
  submitHandlers,
  submitConfig,
  submitData,
  fetcher,
  stringifyAllValues = true,
  ...formProps
}: UseRemixFormOptions<TFieldValues, TContext, TTransformedValues>) => {
  const [isSubmittedSuccessfully, setIsSubmittedSuccessfully] = useState(false);
  const basename = useHref("/");
  const actionSubmit = useSubmit();
  const actionData = useActionData();
  const submit = fetcher?.submit ?? actionSubmit;
  // biome-ignore lint/suspicious/noExplicitAny: <explanation>
  const data: any = fetcher?.data ?? actionData;
  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(() => {
    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);
  // When the network submission is done, set the state to `false`
  useEffect(() => {
    if (!isSubmittingForm) {
      setIsSubmittingNetwork(false);
    }
  }, [isSubmittingForm]);

  // Submits the data to the server when form is valid
  const onSubmit = useMemo(
    () =>
      (
        data: TTransformedValues,
        // biome-ignore lint/suspicious/noExplicitAny: <explanation>
        e: any,
        formEncType?: FormEncType,
        formMethod?: FormMethod,
        formAction?: string,
      ) => {
        setIsSubmittingNetwork(true);
        setIsSubmittedSuccessfully(true);
        const encType = submitConfig?.encType ?? formEncType;
        const method = submitConfig?.method ?? formMethod ?? "post";
        const action = submitConfig?.action ?? formAction;
        const submitPayload = { ...data, ...submitData };
        const formData =
          encType === "application/json"
            ? submitPayload
            : createFormData(submitPayload, stringifyAllValues);

        submit(formData, {
          ...submitConfig,
          method,
          encType,
          action,
        });
      },
    [submit, submitConfig, submitData, stringifyAllValues],
  );

  // eslint-disable-next-line @typescript-eslint/no-empty-function
  const onInvalid = useMemo(() => () => {}, []);

  // React-hook-form uses lazy property getters to avoid re-rendering when properties
  // that aren't being used change. Using getters here preservers that lazy behavior.
  const formState: FormState<TFieldValues> = useMemo(
    () => ({
      get isDirty() {
        return methods.formState.isDirty;
      },
      get isLoading() {
        return methods.formState.isLoading;
      },
      get isSubmitted() {
        return methods.formState.isSubmitted;
      },
      get isSubmitSuccessful() {
        return isSubmittedSuccessfully || methods.formState.isSubmitSuccessful;
      },
      get isSubmitting() {
        return isSubmittingNetwork || methods.formState.isSubmitting;
      },
      get isValidating() {
        return methods.formState.isValidating;
      },
      get isValid() {
        return methods.formState.isValid;
      },
      get disabled() {
        return methods.formState.disabled;
      },
      get submitCount() {
        return methods.formState.submitCount;
      },
      get defaultValues() {
        return methods.formState.defaultValues;
      },
      get dirtyFields() {
        return methods.formState.dirtyFields;
      },
      get touchedFields() {
        return methods.formState.touchedFields;
      },
      get validatingFields() {
        return methods.formState.validatingFields;
      },
      get errors() {
        return methods.formState.errors;
      },
    }),
    [methods.formState, isSubmittedSuccessfully, isSubmittingNetwork],
  );
  const reset = useMemo(
    () =>
      (
        values?: TFieldValues | DefaultValues<TFieldValues> | undefined,
        options?: KeepStateOptions,
      ) => {
        setIsSubmittedSuccessfully(false);
        methods.reset(values, options);
      },
    [methods.reset],
  );

  const register = useMemo(
    () =>
      (
        name: Path<TFieldValues>,
        options?: RegisterOptions<TFieldValues> & {
          disableProgressiveEnhancement?: boolean;
        },
      ) => {
        const defaultValue =
          get(data?.defaultValues, name) ??
          get(methods.formState.defaultValues, name);
        return {
          ...methods.register(name, options),
          ...(!options?.disableProgressiveEnhancement && {
            defaultValue:
              typeof defaultValue === "string" ? defaultValue : undefined,
            defaultChecked:
              typeof defaultValue === "boolean" ? defaultValue : undefined,
          }),
        };
      },
    [methods.register, data?.defaultValues, methods.formState.defaultValues],
  );

  const handleSubmit = useMemo(
    () => (e?: FormEvent<HTMLFormElement>) => {
      const encType = e?.currentTarget?.enctype as FormEncType | undefined;
      const method = e?.currentTarget?.method as FormMethod | undefined;
      const action = e?.currentTarget?.action.replace(
        `${window.location.origin}${basename === "/" ? "" : basename}`,
        "",
      );

      const onValidHandler = submitHandlers?.onValid ?? onSubmit;
      const onInvalidHandler = submitHandlers?.onInvalid ?? onInvalid;

      return methods.handleSubmit(
        (data, e) => onValidHandler(data, e, encType, method, action),
        onInvalidHandler,
      )(e);
    },
    [methods.handleSubmit, submitHandlers, onSubmit, onInvalid, basename],
  );

  const hookReturn = useMemo(
    () => ({
      ...methods,
      handleSubmit,
      reset,
      register,
      formState,
    }),
    [methods, handleSubmit, reset, register, formState],
  );

  return hookReturn;
};
export type UseRemixFormReturn<
  TFieldValues extends FieldValues = FieldValues,
  // biome-ignore lint/suspicious/noExplicitAny: defaults to any type
  TContext = any,
  TTransformedValues = TFieldValues,
> = UseFormReturn<TFieldValues, TContext, TTransformedValues> & {
  handleSubmit: ReturnType<typeof useRemixForm>["handleSubmit"];
  reset: ReturnType<typeof useRemixForm>["reset"];
  register: ReturnType<typeof useRemixForm>["register"];
};
interface RemixFormProviderProps<
  TFieldValues extends FieldValues = FieldValues,
  // biome-ignore lint/suspicious/noExplicitAny: defaults to any type
  TContext = any,
  TTransformedValues = TFieldValues,
> extends Omit<
    UseFormReturn<TFieldValues, TContext, TTransformedValues>,
    "handleSubmit" | "reset"
  > {
  children: ReactNode;
  // biome-ignore lint/suspicious/noExplicitAny: <explanation>
  handleSubmit: any;
  // biome-ignore lint/suspicious/noExplicitAny: <explanation>
  register: any;
  // biome-ignore lint/suspicious/noExplicitAny: <explanation>
  reset: any;
}
export const RemixFormProvider = <
  TFieldValues extends FieldValues = FieldValues,
  // biome-ignore lint/suspicious/noExplicitAny: defaults to any type
  TContext = any,
  TTransformedValues = TFieldValues,
>({
  children,
  ...props
}: RemixFormProviderProps<TFieldValues, TContext, TTransformedValues>) => {
  return <FormProvider {...props}>{children}</FormProvider>;
};
export const useRemixFormContext = <
  TFieldValues extends FieldValues,
  // biome-ignore lint/suspicious/noExplicitAny: defaults to any type
  TContext = any,
  TTransformedValues = TFieldValues,
>() => {
  const methods = useFormContext<TFieldValues, TContext, TTransformedValues>();
  return {
    ...methods,
    // biome-ignore lint/suspicious/noExplicitAny: <explanation>
    handleSubmit: methods.handleSubmit as any as ReturnType<
      UseFormHandleSubmit<TFieldValues, TTransformedValues>
    >,
  };
};


================================================
FILE: src/index.ts
================================================
export {
  parseFormData,
  createFormData,
  getValidatedFormData,
  validateFormData,
  getFormDataFromSearchParams,
  generateFormData,
} from "./utilities";
export * from "./hook";
export type { UseRemixFormOptions } from "./hook";


================================================
FILE: src/middleware/index.ts
================================================
import type { FieldValues, Resolver } from "react-hook-form";
import {
  // data as dataFn,
  type unstable_MiddlewareFunction,
  type unstable_RouterContextProvider,
  unstable_createContext,
} from "react-router";
import {
  getFormData as getFormDataUtility,
  validateFormData,
} from "../utilities";

const formDataContext = unstable_createContext<unknown>();

export function unstable_extractFormDataMiddleware({
  preserveStringified = false,
} = {}): unstable_MiddlewareFunction {
  return async function extractFormDataMiddleware({ request, context }, next) {
    const cloneRequest = request.clone();
    const { receivedValues: formData } = await getFormDataUtility(
      cloneRequest,
      preserveStringified,
    );

    context.set(formDataContext, formData);
    return next();

    /*  if (res instanceof Response && res.status === 422) {
      console.log("middleware response 422", res);

      return new Response(res.body, { status: 200 });
    } */
  };
}

export const getFormData = (context: unstable_RouterContextProvider) =>
  context.get(formDataContext);

export const getValidatedFormData = async <
  TFieldValues extends FieldValues,
  // biome-ignore lint/suspicious/noExplicitAny: defaults to any type
  TContext = any,
  TTransformedValues = TFieldValues,
>(
  context: unstable_RouterContextProvider,
  resolver: Resolver<TFieldValues, TContext, TTransformedValues>,
) => {
  const formData = context.get(formDataContext);
  const data = await validateFormData<
    TFieldValues,
    TContext,
    TTransformedValues
  >(formData, resolver);
  /* if (errors) {
    throw dataFn(
      { errors, receivedValues: formData },
      {
        status: 422,
        headers: new Headers({ "X-Remix-Hook-Form-Validation-Error": "true" }),
      },
    );
  } */
  return { ...data, receivedValues: formData };
};


================================================
FILE: src/utilities/index.test.ts
================================================
import { array, boolean, coerce, date, number, object, string } from "zod";
import {
  createFormData,
  generateFormData,
  getFormDataFromSearchParams,
  getValidatedFormData,
  isGet,
  parseFormData,
  validateFormData,
} from "./index";

import { zodResolver } from "@hookform/resolvers/zod";

describe("createFormData", () => {
  it("should create a FormData object with the provided data", () => {
    const data = {
      name: "John Doe",
      age: 30,
      bool: true,
      object: {
        test: "1",
        number: 2,
      },
      array: [1, 2, 3],
    };
    const formData = createFormData(data);

    expect(formData.get("name")).toEqual(JSON.stringify(data.name));
    expect(formData.get("age")).toEqual(data.age.toString());
    expect(formData.get("object")).toEqual(JSON.stringify(data.object));
    expect(formData.get("array")).toEqual(JSON.stringify(data.array));
    expect(formData.get("bool")).toEqual(data.bool.toString());
  });

  it("should create a FormData object with the provided data and remove undefined values", () => {
    const data = {
      name: "John Doe",
      age: 30,
      bool: true,
      b: undefined,
      a: null,
      object: {
        test: "1",
        number: 2,
      },
      array: [1, 2, 3],
    };
    const formData = createFormData(data);

    expect(formData.get("name")).toEqual(JSON.stringify(data.name));
    expect(formData.get("age")).toEqual(data.age.toString());
    expect(formData.get("object")).toEqual(JSON.stringify(data.object));
    expect(formData.get("array")).toEqual(JSON.stringify(data.array));
    expect(formData.get("bool")).toEqual(data.bool.toString());
    expect(formData.get("b")).toEqual(null);
    expect(formData.get("a")).toEqual("null");
  });

  it("should handle null data", () => {
    // biome-ignore lint/suspicious/noExplicitAny: <explanation>
    const formData = createFormData(null as any);
    expect(formData).toBeTruthy();
  });

  it("should handle undefined data", () => {
    // biome-ignore lint/suspicious/noExplicitAny: <explanation>
    const formData = createFormData(undefined as any);
    expect(formData).toBeTruthy();
  });

  it("should handle Date data", () => {
    const date = "2024-01-01T00:00:00.000Z";
    const formData = createFormData({ date: new Date(date) }, false);
    expect(formData.get("date")).toEqual(date);
  });
});

describe("parseFormData", () => {
  // Mock the Request and formData methods
  beforeAll(() => {
    global.Request = vi.fn();
    global.Request.prototype.formData = vi.fn();
  });

  // Reset the mocks after each test
  afterEach(() => {
    vi.resetAllMocks();
  });

  it("should parse the data from the request object", async () => {
    const data = {
      name: "John Doe",
      age: "30",
    };
    const request = new Request("http://localhost:3000");
    const requestFormDataSpy = vi.spyOn(request, "formData");
    requestFormDataSpy.mockResolvedValueOnce(createFormData(data));
    const parsedData = await parseFormData<typeof data>(request);
    expect(parsedData).toEqual(data);
  });

  it("should return an empty object if no formData exists", async () => {
    const request = new Request("http://localhost:3000");
    const requestFormDataSpy = vi.spyOn(request, "formData");
    requestFormDataSpy.mockResolvedValueOnce(createFormData({}));
    const parsedData = await parseFormData(request);
    expect(parsedData).toEqual({});
  });

  it("should return formData if NO js was used and formData was passed as is", async () => {
    const formData = new FormData();
    formData.append("name", "John Doe");
    formData.append("age", "30");
    formData.append("hobbies.0", "Reading");
    formData.append("hobbies.1", "Writing");
    formData.append("hobbies.2", "Coding");
    formData.append("other.skills.0", "testing");
    formData.append("other.skills.1", "testing");
    formData.append("other.something", "else");
    const request = new Request("http://localhost:3000");
    const requestFormDataSpy = vi.spyOn(request, "formData");
    requestFormDataSpy.mockResolvedValueOnce(formData);
    const parsedData = await parseFormData(request);
    expect(parsedData).toEqual({
      name: "John Doe",
      age: 30,
      hobbies: ["Reading", "Writing", "Coding"],
      other: {
        skills: ["testing", "testing"],
        something: "else",
      },
    });
  });

  it("should handle multiple file input", async () => {
    const request = new Request("http://localhost:3000");
    const requestFormDataSpy = vi.spyOn(request, "formData");
    const blob = new Blob(["Hello, world!"], { type: "text/plain" });
    const mockFormData = new FormData();
    mockFormData.append("files", blob);
    mockFormData.append("files", blob);
    requestFormDataSpy.mockResolvedValueOnce(mockFormData);
    const data = await parseFormData<{ files: Blob[] }>(request);
    expect(data.files).toBeTypeOf("object");
    expect(Array.isArray(data.files)).toBe(true);
    expect(data.files).toHaveLength(2);
    expect(data.files[0]).toBeInstanceOf(File);
    expect(data.files[1]).toBeInstanceOf(File);
  });

  it("should not throw an error when a file is passed in", async () => {
    const request = new Request("http://localhost:3000");
    const requestFormDataSpy = vi.spyOn(request, "formData");
    const blob = new Blob(["Hello, world!"], { type: "text/plain" });
    const mockFormData = new FormData();
    mockFormData.append("file", blob);
    requestFormDataSpy.mockResolvedValueOnce(mockFormData);
    const data = await parseFormData<{ file: Blob }>(request);
    expect(data.file).toBeTypeOf("object");
  });
});

describe("generateFormData", () => {
  it("should generate an output object for flat form data", () => {
    const formData = new FormData();
    formData.append("name", "John Doe");
    formData.append("email", "johndoe@example.com");

    const expectedOutput = {
      name: "John Doe",
      email: "johndoe@example.com",
    };

    expect(generateFormData(formData)).toEqual(expectedOutput);
  });

  it("should generate an output object for nested form data", () => {
    const formData = new FormData();
    formData.append("user.name.first", "John");
    formData.append("user.name.last", "Doe");
    formData.append("user.email", "johndoe@example.com");

    const expectedOutput = {
      user: {
        name: {
          first: "John",
          last: "Doe",
        },
        email: "johndoe@example.com",
      },
    };

    expect(generateFormData(formData)).toEqual(expectedOutput);
  });

  it("should generate an output object with arrays for integer indexes", () => {
    const formData = new FormData();
    formData.append("user.roles.0", "admin");
    formData.append("user.roles.1", "editor");
    formData.append("user.roles.2", "contributor");

    const expectedOutput = {
      user: {
        roles: ["admin", "editor", "contributor"],
      },
    };

    expect(generateFormData(formData)).toEqual(expectedOutput);
  });
});

describe("isGet", () => {
  it("returns true if the request method is GET", () => {
    const request = { method: "GET" };
    expect(isGet(request)).toBe(true);
  });

  it("returns true if the request method is get", () => {
    const request = { method: "get" };
    expect(isGet(request)).toBe(true);
  });

  it("returns false if the request method is POST", () => {
    const request = { method: "POST" };
    expect(isGet(request)).toBe(false);
  });
});

describe("getFormDataFromSearchParams", () => {
  it("should return an empty FormData object when there are no search params", () => {
    const request = {
      url: "http://localhost:3000/",
    };

    const formData = getFormDataFromSearchParams(request);
    expect(formData).toStrictEqual({});
  });

  it("should return a FormData object with search params", () => {
    const request = {
      url: "http://localhost:3000/?name=John+Doe&email=johndoe@example.com",
    };

    const formData = getFormDataFromSearchParams(request);
    expect(formData).toStrictEqual({
      name: "John Doe",
      email: "johndoe@example.com",
    });
  });

  it("should return a FormData object with nested search params", () => {
    const request = {
      url: "http://localhost:3000/?user.name.first=John&user.name.last=Doe&user.email=johndoe@example.com",
    };

    const formData = getFormDataFromSearchParams(request);

    expect(formData).toStrictEqual({
      user: {
        name: {
          first: "John",
          last: "Doe",
        },
        email: "johndoe@example.com",
      },
    });
  });

  it("should convert array search params to FormData object with arrays", () => {
    const request = {
      url: "http://localhost:3000/?colors[]=red&colors[]=green&colors[]=blue&numbers[0]=1&numbers[1]=2&numbers[2]=3",
    };
    const formData = getFormDataFromSearchParams(request);

    expect(formData).toStrictEqual({
      colors: ["red", "green", "blue"],
      numbers: [1, 2, 3],
    });
  });
});

describe("validateFormData", () => {
  it("should return an error when parsing date object without coerce", async () => {
    const formData = createFormData({
      date: new Date(2024, 1, 1),
    });
    const returnData = await validateFormData(
      formData,
      zodResolver(object({ date: date() })),
    );
    expect(returnData.errors).toStrictEqual({
      date: {
        message: "Expected date, received string",
        type: "invalid_type",
        ref: undefined,
      },
    });
    expect(returnData.data).toStrictEqual(undefined);
  });

  it("should return an error when parsing date object with coerce", async () => {
    const formData = createFormData({
      date: new Date(2024, 1, 1),
    });
    const returnData = await validateFormData(
      formData,
      zodResolver(object({ date: coerce.date() })),
    );
    expect(returnData.errors).toStrictEqual({
      date: {
        message: "Invalid date",
        type: "invalid_date",
        ref: undefined,
      },
    });
    expect(returnData.data).toStrictEqual(undefined);
  });

  it("should return a correct value when formatting as object", async () => {
    const formData = createFormData(
      {
        date: new Date(2024, 1, 1),
      },
      false,
    );
    const returnData = await validateFormData(
      formData,
      zodResolver(object({ date: coerce.date() })),
    );
    expect(returnData.errors).toStrictEqual(undefined);
    expect(returnData.data).toStrictEqual({
      date: new Date(2024, 1, 1),
    });
  });

  it("should return an empty error object and valid data if there are no errors", async () => {
    const formData = {
      name: "John Doe",
      email: "email@email.com",
    };

    const returnData = await validateFormData(
      formData,
      zodResolver(object({ name: string(), email: string().email() })),
    );
    expect(returnData.errors).toStrictEqual(undefined);
    expect(returnData.data).toStrictEqual(formData);
  });

  it("should return an error object and no data if there are errors", async () => {
    const formData = {
      email: "email",
      name: "John Doe",
    };
    const returnData = await validateFormData(
      formData,
      zodResolver(object({ name: string(), email: string().email() })),
    );
    expect(returnData.errors).toStrictEqual({
      email: {
        message: "Invalid email",
        type: "invalid_string",
        ref: undefined,
      },
    });
    expect(returnData.data).toStrictEqual(undefined);
  });
});

describe("createFormData", () => {
  it("accepts a file array and maps it properly", async () => {
    const formData = createFormData({
      files: [new File(["test"], "test.txt"), new File(["test2"], "test2.txt")],
    });
    const files = formData.getAll("files");
    expect(files).toHaveLength(2);
    expect(files[0]).toBeInstanceOf(File);
    expect(files[1]).toBeInstanceOf(File);
  });

  it("accepts a blob array and maps it properly", async () => {
    const formData = createFormData({
      files: [
        new Blob(["test"], { type: "text/plain" }),
        new Blob(["test2"], { type: "text/plain" }),
      ],
    });
    const files = formData.getAll("files");
    expect(files).toHaveLength(2);
    expect(files[0]).toBeInstanceOf(Blob);
    expect(files[1]).toBeInstanceOf(Blob);
  });

  it("accepts a file and adds it properly to formData", async () => {
    const formData = createFormData({
      file: new File(["test"], "test.txt"),
    });
    const file = formData.get("file");
    expect(file).toBeInstanceOf(File);
  });

  it("accepts a blob and adds it properly to formData", async () => {
    const formData = createFormData({
      file: new Blob(["test"], { type: "text/plain" }),
    });
    const file = formData.get("file");
    expect(file).toBeInstanceOf(Blob);
  });

  it("doesn't mess up types when passed from frontend to backend", async () => {
    const formData = createFormData({
      name: "123",
      age: 30,
      hobbies: ["Reading", "Writing", "Coding"],
      boolean: true,
      numbers: [1, 2, 3],
      other: {
        skills: ["testing", "testing"],
        something: "else",
      },
    });
    const request = new Request("http://localhost:3000", { method: "POST" });
    const requestFormDataSpy = vi.spyOn(request, "formData");

    requestFormDataSpy.mockResolvedValueOnce(formData);
    const parsed = await parseFormData<typeof formData>(request);

    expect(parsed).toStrictEqual({
      name: "123",
      age: 30,
      hobbies: ["Reading", "Writing", "Coding"],
      boolean: true,
      numbers: [1, 2, 3],
      other: {
        skills: ["testing", "testing"],
        something: "else",
      },
    });
  });

  it("doesn't send undefined values to the backend but sends null values", async () => {
    const formData = createFormData({
      name: "123",
      age: 30,
      hobbies: ["Reading", "Writing", "Coding"],
      boolean: true,
      a: null,
      b: undefined,
      numbers: [1, 2, 3],
      other: {
        skills: ["testing", "testing"],
        something: "else",
      },
    });
    const request = new Request("http://localhost:3000", { method: "POST" });
    const requestFormDataSpy = vi.spyOn(request, "formData");

    requestFormDataSpy.mockResolvedValueOnce(formData);
    const parsed = await parseFormData<typeof formData>(request);

    expect(parsed).toStrictEqual({
      name: "123",
      age: 30,
      hobbies: ["Reading", "Writing", "Coding"],
      boolean: true,
      a: null,
      numbers: [1, 2, 3],
      other: {
        skills: ["testing", "testing"],
        something: "else",
      },
    });
  });
});

describe("getValidatedFormData", () => {
  it("gets valid form data from formData", async () => {
    const formData = {
      name: "John Doe",
      age: 30,
      hobbies: ["Reading", "Writing", "Coding"],
      boolean: true,
      numbers: [1, 2, 3],
      other: {
        skills: ["testing", "testing"],
        something: "else",
      },
    };
    const data = createFormData(formData);
    const schema = object({
      name: string(),
      age: number(),
      boolean: boolean(),
      numbers: array(number()),
      hobbies: array(string()),
      other: object({
        skills: array(string()),
        something: string(),
      }),
    });
    const validatedFormData = await getValidatedFormData(
      data,
      zodResolver(schema),
    );
    expect(validatedFormData).toStrictEqual({
      data: formData,
      receivedValues: formData,
      errors: undefined,
    });
  });

  it("gets valid form data from a GET request", async () => {
    const request = {
      method: "GET",
      url: "http://localhost:3000/?user.name=john&colors[]=red&colors[]=green&colors[]=blue&numbers[0]=1&numbers[1]=2&numbers[2]=3",
    };

    const schema = object({
      user: object({
        name: string(),
      }),
      colors: array(string()),
      numbers: array(number()),
    });
    const formData = await getValidatedFormData(
      // biome-ignore lint/suspicious/noExplicitAny: <explanation>
      request as any,
      zodResolver(schema),
    );
    expect(formData).toStrictEqual({
      data: {
        user: {
          name: "john",
        },
        colors: ["red", "green", "blue"],
        numbers: [1, 2, 3],
      },
      receivedValues: {
        user: {
          name: "john",
        },
        colors: ["red", "green", "blue"],
        numbers: [1, 2, 3],
      },
      errors: undefined,
    });
  });

  it("gets valid form data from a POST request when it is js", async () => {
    const formData = {
      name: "John Doe",
      age: 30,
      hobbies: ["Reading", "Writing", "Coding"],
      boolean: true,
      numbers: [1, 2, 3],
      other: {
        skills: ["testing", "testing"],
        something: "else",
      },
    };
    const request = new Request("http://localhost:3000", { method: "POST" });
    const requestFormDataSpy = vi.spyOn(request, "formData");
    const data = createFormData(formData);
    requestFormDataSpy.mockResolvedValueOnce(data);

    const schema = object({
      name: string(),
      age: number(),
      boolean: boolean(),
      numbers: array(number()),
      hobbies: array(string()),
      other: object({
        skills: array(string()),
        something: string(),
      }),
    });
    const validatedFormData = await getValidatedFormData(
      // biome-ignore lint/suspicious/noExplicitAny: <explanation>
      request as any,
      zodResolver(schema),
    );
    expect(validatedFormData).toStrictEqual({
      data: formData,
      receivedValues: formData,
      errors: undefined,
    });
  });

  it("gets valid form data from a POST request when it is no js", async () => {
    const formData = new FormData();
    formData.append("name", "John Doe");
    formData.append("age", "30");
    formData.append("hobbies.0", "Reading");
    formData.append("hobbies.1", "Writing");
    formData.append("hobbies.2", "Coding");
    formData.append("other.skills.0", "testing");
    formData.append("other.skills.1", "testing");
    formData.append("other.something", "else");
    const request = new Request("http://localhost:3000", { method: "POST" });
    const requestFormDataSpy = vi.spyOn(request, "formData");
    requestFormDataSpy.mockResolvedValueOnce(formData);

    const schema = object({
      name: string(),
      age: number(),
      hobbies: array(string()),
      other: object({
        skills: array(string()),
        something: string(),
      }),
    });
    const returnData = await getValidatedFormData(
      // biome-ignore lint/suspicious/noExplicitAny: <explanation>
      request as any,
      zodResolver(schema),
    );
    expect(returnData).toStrictEqual({
      data: {
        name: "John Doe",
        age: 30,
        hobbies: ["Reading", "Writing", "Coding"],
        other: {
          skills: ["testing", "testing"],
          something: "else",
        },
      },
      receivedValues: {
        name: "John Doe",
        age: 30,
        hobbies: ["Reading", "Writing", "Coding"],
        other: {
          skills: ["testing", "testing"],
          something: "else",
        },
      },
      errors: undefined,
    });
  });
});


================================================
FILE: src/utilities/index.ts
================================================
import type { FieldErrors, FieldValues, Resolver } from "react-hook-form";

const tryParseJSON = (value: string | File | Blob) => {
  if (value instanceof File || value instanceof Blob) {
    return value;
  }
  try {
    const json = JSON.parse(value);

    return json;
  } catch (e) {
    return value;
  }
};

/**
 * Generates an output object from the given form data, where the keys in the output object retain
 * the structure of the keys in the form data. Keys containing integer indexes are treated as arrays.
 */
export const generateFormData = (
  formData: FormData | URLSearchParams,
  preserveStringified = false,
) => {
  // Initialize an empty output object.
  // biome-ignore lint/suspicious/noExplicitAny: <explanation>
  const outputObject: Record<any, any> = {};

  // See if a key is repeated, and then handle that in a special case
  const keyCounts: Record<string, number> = {};
  for (const key of formData.keys()) {
    keyCounts[key] = (keyCounts[key] ?? 0) + 1;
  }

  // Iterate through each key-value pair in the form data.
  for (const [key, value] of formData.entries()) {
    // Get the current key's count
    const keyCount = keyCounts[key];

    // Try to convert data to the original type, otherwise return the original value
    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.
    let currentObject = outputObject;

    // Iterate through each key part except for the last one.
    for (let i = 0; i < keyParts.length - 1; i++) {
      // Get the current key part.
      const keyPart = keyParts[i];
      // If the current object doesn't have a property with the current key part,
      // initialize it as an object or array depending on whether the next key part is a valid integer index or not.
      if (!currentObject[keyPart]) {
        currentObject[keyPart] = /^\d+$/.test(keyParts[i + 1]) ? [] : {};
      }
      // Move the current object pointer to the next level of the output object.
      currentObject = currentObject[keyPart];
    }

    // Get the last key part.
    const lastKeyPart = keyParts[keyParts.length - 1];
    const lastKeyPartIsArray = /\[\d*\]$|\[\]$/.test(lastKeyPart);

    // Handles array[] or array[0] cases
    if (lastKeyPartIsArray) {
      const key = lastKeyPart.replace(/\[\d*\]$|\[\]$/, "");
      if (!currentObject[key]) {
        currentObject[key] = [];
      }

      currentObject[key].push(data);
    }
    // Handles array.foo.0 cases
    else {
      // If the last key part is a valid integer index, push the value to the current array.
      if (/^\d+$/.test(lastKeyPart)) {
        currentObject.push(data);
      }
      // Otherwise, set a property on the current object with the last key part and the corresponding value.
      else {
        if (keyCount > 1) {
          if (!currentObject[key]) {
            currentObject[key] = [];
          }
          currentObject[key].push(data);
        } else {
          currentObject[lastKeyPart] = data;
        }
      }
    }
  }

  // Return the output object.
  return outputObject;
};

export const getFormDataFromSearchParams = <T extends FieldValues>(
  request: Pick<Request, "url">,
  preserveStringified = false,
): T => {
  const searchParams = new URL(request.url).searchParams;

  return generateFormData(searchParams, preserveStringified);
};

export const isGet = (request: Pick<Request, "method">) =>
  request.method === "GET" || request.method === "get";

type ReturnData<
  TFieldValues extends FieldValues,
  TTransformedValues = TFieldValues,
> =
  | {
      data: TTransformedValues;
      errors: undefined;
      receivedValues: Partial<TFieldValues>;
    }
  | {
      data: undefined;
      errors: FieldErrors<TFieldValues>;
      receivedValues: Partial<TFieldValues>;
    };
/**
 * Parses the data from an HTTP request and validates it against a schema. Works in both loaders and actions, in loaders it extracts the data from the search params.
 * In actions it extracts it from request formData.
 *
 * @returns A Promise that resolves to an object containing the validated data or any errors that occurred during validation.
 */
export const getValidatedFormData = async <
  TFieldValues extends FieldValues,
  // biome-ignore lint/suspicious/noExplicitAny: any by default
  TContext = any,
  TTransformedValues = TFieldValues,
>(
  request: Request | FormData,
  resolver: Resolver<TFieldValues, TContext, TTransformedValues>,
  preserveStringified = false,
): Promise<ReturnData<TFieldValues, TTransformedValues>> => {
  const { receivedValues } = await getFormData<TFieldValues>(
    request,
    preserveStringified,
  );

  const data = await validateFormData(receivedValues, resolver);

  return { ...data, receivedValues };
};

/**
 * Parses the data from an HTTP request depending on the request method and returns the parsed form data.
 *
 * @returns A Promise that resolves to an object containing the data
 */
export const getFormData = async <T extends FieldValues>(
  request: Request | FormData,
  preserveStringified = false,
) => {
  const receivedValues =
    "url" in request && isGet(request)
      ? getFormDataFromSearchParams<T>(request, preserveStringified)
      : await parseFormData<T>(request, preserveStringified);

  return { receivedValues };
};

/**
 * Helper method used in actions to validate the form data parsed from the frontend using zod and return a json error if validation fails.
 * @param data Data to validate
 * @param resolver Schema to validate and cast the data with
 * @returns Returns the validated data if successful, otherwise returns the error object
 */
export const validateFormData = async <
  TFieldValues extends FieldValues,
  TContext,
  TTransformedValues = TFieldValues,
>(
  // biome-ignore lint/suspicious/noExplicitAny: <explanation>
  data: any,
  resolver: Resolver<TFieldValues, TContext, TTransformedValues>,
) => {
  const dataToValidate =
    data instanceof FormData ? Object.fromEntries(data) : data;
  const { errors, values } = await resolver(dataToValidate, {} as TContext, {
    shouldUseNativeValidation: false,
    fields: {},
  });

  if (Object.keys(errors).length > 0) {
    return { errors: errors as FieldErrors<TFieldValues>, data: undefined };
  }

  return { errors: undefined, data: values as TTransformedValues };
};
/**
  Creates a new instance of FormData with the specified data and key.
  @template T - The type of the data parameter. It can be any type of FieldValues.
  @param {T} data - The data to be added to the FormData. It can be either an object of type FieldValues.
  @param {boolean} stringifyAll - Should the form data be stringified or not (default: true) eg: {a: '"string"', b: "1"} vs {a: "string", b: "1"}
  @returns {FormData} - The FormData object with the data added to it.
*/
export const createFormData = <T extends FieldValues>(
  data: T,
  stringifyAll = true,
): FormData => {
  const formData = new FormData();
  if (!data) {
    return formData;
  }
  for (const [key, value] of Object.entries(data)) {
    // Skip undefined values
    if (value === undefined) {
      continue;
    }
    // Handle FileList
    if (typeof FileList !== "undefined" && value instanceof FileList) {
      for (let i = 0; i < value.length; i++) {
        formData.append(key, value[i]);
      }
      continue;
    }
    // Handle array of File and Blob objects
    if (
      Array.isArray(value) &&
      value.length > 0 &&
      value.every((item) => item instanceof File || item instanceof Blob)
    ) {
      for (let i = 0; i < value.length; i++) {
        formData.append(key, value[i]);
      }
      continue;
    }
    // Handle File or Blob
    if (value instanceof File || value instanceof Blob) {
      formData.append(key, value);
      continue;
    }
    // Stringify all values if set
    if (stringifyAll) {
      formData.append(key, JSON.stringify(value));
      continue;
    }
    // Handle strings
    if (typeof value === "string") {
      formData.append(key, value);
      continue;
    }
    // Handle dates
    if (value instanceof Date) {
      formData.append(key, value.toISOString());
      continue;
    }
    // Handle all the other values

    formData.append(key, JSON.stringify(value));
  }

  return formData;
};

/**
Parses the specified Request object's FormData to retrieve the data associated with the specified key.
Or parses the specified FormData to retrieve the data 
  */

// biome-ignore lint/complexity/noUselessTypeConstraint: <explanation>
export const parseFormData = async <T extends unknown>(
  request: Request | FormData,
  preserveStringified = false,
): Promise<T> => {
  const formData =
    request instanceof Request ? await request.formData() : request;
  return generateFormData(formData, preserveStringified);
};


================================================
FILE: test-apps/react-router/.dockerignore
================================================
.react-router
build
node_modules
README.md

================================================
FILE: test-apps/react-router/.gitignore
================================================
.env
!.env.example
.DS_Store
.react-router
build
node_modules
*.tsbuildinfo


================================================
FILE: test-apps/react-router/Dockerfile
================================================
FROM node:20-alpine as development-dependencies-env
COPY . /app
WORKDIR /app
RUN npm ci

FROM node:20-alpine as production-dependencies-env
COPY ./package.json package-lock.json /app/
WORKDIR /app
RUN npm ci --omit=dev

FROM node:20-alpine AS build-env
COPY . /app/
COPY --from=development-dependencies-env /app/node_modules /app/node_modules
WORKDIR /app
RUN npm run build

FROM node:20-alpine
COPY ./package.json package-lock.json /app/
COPY --from=production-dependencies-env /app/node_modules /app/node_modules
COPY --from=build-env /app/build /app/build
WORKDIR /app
CMD ["npm", "run", "start"]

================================================
FILE: test-apps/react-router/Dockerfile.bun
================================================
FROM oven/bun:1 as dependencies-env
COPY . /app

FROM dependencies-env as development-dependencies-env
COPY ./package.json bun.lockb /app/
WORKDIR /app
RUN bun i --frozen-lockfile

FROM dependencies-env as production-dependencies-env
COPY ./package.json bun.lockb /app/
WORKDIR /app
RUN bun i --production

FROM dependencies-env AS build-env
COPY ./package.json bun.lockb /app/
COPY --from=development-dependencies-env /app/node_modules /app/node_modules
WORKDIR /app
RUN bun run build

FROM dependencies-env
COPY ./package.json bun.lockb /app/
COPY --from=production-dependencies-env /app/node_modules /app/node_modules
COPY --from=build-env /app/build /app/build
WORKDIR /app
CMD ["bun", "run", "start"]

================================================
FILE: test-apps/react-router/Dockerfile.pnpm
================================================
FROM node:20-alpine as dependencies-env
RUN npm i -g pnpm
COPY . /app

FROM dependencies-env as development-dependencies-env
COPY ./package.json pnpm-lock.yaml /app/
WORKDIR /app
RUN pnpm i --frozen-lockfile

FROM dependencies-env as production-dependencies-env
COPY ./package.json pnpm-lock.yaml /app/
WORKDIR /app
RUN pnpm i --prod --frozen-lockfile

FROM dependencies-env AS build-env
COPY ./package.json pnpm-lock.yaml /app/
COPY --from=development-dependencies-env /app/node_modules /app/node_modules
WORKDIR /app
RUN pnpm build

FROM dependencies-env
COPY ./package.json pnpm-lock.yaml /app/
COPY --from=production-dependencies-env /app/node_modules /app/node_modules
COPY --from=build-env /app/build /app/build
WORKDIR /app
CMD ["pnpm", "start"]

================================================
FILE: test-apps/react-router/README.md
================================================
# Welcome to React Router!

A modern, production-ready template for building full-stack React applications using React Router.

## Features

- 🚀 Server-side rendering
- ⚡️ Hot Module Replacement (HMR)
- 📦 Asset bundling and optimization
- 🔄 Data loading and mutations
- 🔒 TypeScript by default
- 🎉 TailwindCSS for styling
- 📖 [React Router docs](https://reactrouter.com/)

## Getting Started

### Installation

Install the dependencies:

```bash
npm install
```

### Development

Start the development server with HMR:

```bash
npm run dev
```

Your application will be available at `http://localhost:5173`.

## Building for Production

Create a production build:

```bash
npm run build
```

## Deployment

### Docker Deployment

This template includes three Dockerfiles optimized for different package managers:

- `Dockerfile` - for npm
- `Dockerfile.pnpm` - for pnpm
- `Dockerfile.bun` - for bun

To build and run using Docker:

```bash
# For npm
docker build -t my-app .

# For pnpm
docker build -f Dockerfile.pnpm -t my-app .

# For bun
docker build -f Dockerfile.bun -t my-app .

# Run the container
docker run -p 3000:3000 my-app
```

The containerized application can be deployed to any platform that supports Docker, including:

- AWS ECS
- Google Cloud Run
- Azure Container Apps
- Digital Ocean App Platform
- Fly.io
- Railway

### DIY Deployment

If you're familiar with deploying Node applications, the built-in app server is production-ready.

Make sure to deploy the output of `npm run build`

```
├── package.json
├── package-lock.json (or pnpm-lock.yaml, or bun.lockb)
├── build/
│   ├── client/    # Static assets
│   └── server/    # Server-side code
```

## Styling

This template comes with [Tailwind CSS](https://tailwindcss.com/) already configured for a simple default starting experience. You can use whatever CSS framework you prefer.

---

Built with ❤️ using React Router.


================================================
FILE: test-apps/react-router/app/app.css
================================================
@tailwind base;
@tailwind components;
@tailwind utilities;

html,
body {
  @apply bg-white dark:bg-gray-950;

  @media (prefers-color-scheme: dark) {
    color-scheme: dark;
  }
}


================================================
FILE: test-apps/react-router/app/root.tsx
================================================
import {
  Links,
  Meta,
  Outlet,
  Scripts,
  ScrollRestoration,
  isRouteErrorResponse,
} from "react-router";

import type { Route } from "./+types/root";
import "./app.css";
import { unstable_extractFormDataMiddleware } from "remix-hook-form/middleware";

export const loader = ({ context }: Route.LoaderArgs) => {
  return null;
};

export const links: Route.LinksFunction = () => [
  { rel: "preconnect", href: "https://fonts.googleapis.com" },
  {
    rel: "preconnect",
    href: "https://fonts.gstatic.com",
    crossOrigin: "anonymous",
  },
  {
    rel: "stylesheet",
    href: "https://fonts.googleapis.com/css2?family=Inter:ital,opsz,wght@0,14..32,100..900;1,14..32,100..900&display=swap",
  },
];

export function Layout({ children }: { children: React.ReactNode }) {
  return (
    <html lang="en">
      <head>
        <meta charSet="utf-8" />
        <meta name="viewport" content="width=device-width, initial-scale=1" />
        <Meta />
        <Links />
      </head>
      <body>
        {children}
        <ScrollRestoration />
        <Scripts />
      </body>
    </html>
  );
}

export default function App() {
  return <Outlet />;
}

export const unstable_middleware = [unstable_extractFormDataMiddleware()];

export function ErrorBoundary({ error }: Route.ErrorBoundaryProps) {
  let message = "Oops!";
  let details = "An unexpected error occurred.";
  let stack: string | undefined;
  console.log(error);
  if (isRouteErrorResponse(error)) {
    message = error.status === 404 ? "404" : "Error";
    details =
      error.status === 404
        ? "The requested page could not be found."
        : error.statusText || details;
  } else if (import.meta.env.DEV && error && error instanceof Error) {
    details = error.message;
    stack = error.stack;
  }

  return (
    <main className="pt-16 p-4 container mx-auto">
      <h1>{message}</h1>
      <p>{details}</p>
      {stack && (
        <pre className="w-full p-4 overflow-x-auto">
          <code>{stack}</code>
        </pre>
      )}
    </main>
  );
}


================================================
FILE: test-apps/react-router/app/routes/home.tsx
================================================
import { zodResolver } from "@hookform/resolvers/zod";
import { type ClientActionFunctionArgs, Form, useFetcher } from "react-router";
import {
  type ActionFunctionArgs,
  type LoaderFunctionArgs,
  data,
} from "react-router";
import {
  RemixFormProvider,
  getFormDataFromSearchParams,
  useRemixForm,
} from "remix-hook-form";
import { getFormData, getValidatedFormData } from "remix-hook-form/middleware";
import { z } from "zod";

const fileListSchema = z.any().refine((files) => {
  return (
    typeof files === "object" &&
    Symbol.iterator in files &&
    !Array.isArray(files) &&
    Array.from(files).every((file: any) => file instanceof File)
  );
});

const FormDataZodSchema = z.object({
  email: z.string().trim().nonempty("validation.required"),
  password: z.string().trim().nonempty("validation.required"),
  number: z.number({ coerce: true }).int().positive(),
  redirectTo: z.string().optional(),
  boolean: z.boolean().optional(),
  date: z.date().or(z.string()),
  null: z.null(),
  files: fileListSchema.optional(),
  options: z.array(z.string()).optional(),
  checkboxes: z.array(z.string()).optional(),
});

const resolver = zodResolver(FormDataZodSchema);
type FormData = z.infer<typeof FormDataZodSchema>;

export const loader = async ({ context }: LoaderFunctionArgs) => {
  const searchParamsFormData = await getFormData(context);
  return { result: "success" };
};
export const action = async ({ context }: ActionFunctionArgs) => {
  const { data, errors, receivedValues } = await getValidatedFormData<FormData>(
    context,
    resolver,
  );
  if (errors) {
    return { errors, receivedValues };
  }

  console.log(
    "File names:",
    // since files is of type "any", we need to assert its type here
    data.files?.map((file: File) => file.name).join(", "),
  );

  console.log("Selected options:", data.options);

  return { result: "success" };
};

export default function Index() {
  const fetcher = useFetcher();
  const methods = useRemixForm({
    resolver,
    fetcher,
    defaultValues: {
      redirectTo: undefined,
      number: 4,
      email: "test@test.com",
      password: "test",
      date: new Date(),
      boolean: true,
      null: null,
      files: undefined,
      options: undefined,
      checkboxes: undefined,
    },

    submitData: { test: "test" },
  });
  const { register, handleSubmit, formState, watch, setError } = methods;

  const checkbox = watch("boolean");
  return (
    <RemixFormProvider {...methods}>
      <p>Add a thing...</p>
      <Form method="POST" encType="multipart/form-data" onSubmit={handleSubmit}>
        <label>
          Boolean
          <input type="checkbox" {...register("boolean")} />
          {formState.errors.boolean?.message}
        </label>
        <label>
          number
          <input type="number" {...register("number")} />
          {formState.errors.number?.message}
        </label>
        <label>
          Multiple Files
          <input type="file" {...register("files")} multiple />
          {formState.errors.files?.message}
        </label>
        <label>
          Selected Options
          <select {...register("options")} multiple>
            <option value="option1">Option 1</option>
            <option value="option2">Option 2</option>
            <option value="option3">Option 3</option>
          </select>
          {formState.errors.options?.message}
        </label>
        <label>
          Checkboxes
          <fieldset>
            <legend>Select your preferences:</legend>
            <label>
              <input
                type="checkbox"
                value="preference1"
                {...register("checkboxes")}
              />
              Preference 1
            </label>
            <label>
              <input
                type="checkbox"
                value="preference2"
                {...register("checkboxes")}
              />
              Preference 2
            </label>
            <label>
              <input
                type="checkbox"
                value="preference3"
                {...register("checkboxes")}
              />{" "}
              Preference 3
            </label>
          </fieldset>
          {formState.errors.checkboxes?.message}
        </label>

        <div>
          <button formMethod="post" type="submit" className="button">
            Add
          </button>
        </div>
      </Form>
    </RemixFormProvider>
  );
}


================================================
FILE: test-apps/react-router/app/routes.ts
================================================
import { type RouteConfig, index } from "@react-router/dev/routes";

export default [index("routes/home.tsx")] satisfies RouteConfig;


================================================
FILE: test-apps/react-router/app/welcome/welcome.tsx
================================================
import logoDark from "./logo-dark.svg";
import logoLight from "./logo-light.svg";

export function Welcome() {
  return (
    <main className="flex items-center justify-center pt-16 pb-4">
      <div className="flex-1 flex flex-col items-center gap-16 min-h-0">
        <header className="flex flex-col items-center gap-9">
          <div className="w-[500px] max-w-[100vw] p-4">
            <img
              src={logoLight}
              alt="React Router"
              className="block w-full dark:hidden"
            />
            <img
              src={logoDark}
              alt="React Router"
              className="hidden w-full dark:block"
            />
          </div>
        </header>
        <div className="max-w-[300px] w-full space-y-6 px-4">
          <nav className="rounded-3xl border border-gray-200 p-6 dark:border-gray-700 space-y-4">
            <p className="leading-6 text-gray-700 dark:text-gray-200 text-center">
              What&apos;s next?
            </p>
            <ul>
              {resources.map(({ href, text, icon }) => (
                <li key={href}>
                  <a
                    className="group flex items-center gap-3 self-stretch p-3 leading-normal text-blue-700 hover:underline dark:text-blue-500"
                    href={href}
                    target="_blank"
                    rel="noreferrer"
                  >
                    {icon}
                    {text}
                  </a>
                </li>
              ))}
            </ul>
          </nav>
        </div>
      </div>
    </main>
  );
}

const resources = [
  {
    href: "https://reactrouter.com/docs",
    text: "React Router Docs",
    icon: (
      <svg
        xmlns="http://www.w3.org/2000/svg"
        width="24"
        height="20"
        viewBox="0 0 20 20"
        fill="none"
        className="stroke-gray-600 group-hover:stroke-current dark:stroke-gray-300"
      >
        <path
          d="M9.99981 10.0751V9.99992M17.4688 17.4688C15.889 19.0485 11.2645 16.9853 7.13958 12.8604C3.01467 8.73546 0.951405 4.11091 2.53116 2.53116C4.11091 0.951405 8.73546 3.01467 12.8604 7.13958C16.9853 11.2645 19.0485 15.889 17.4688 17.4688ZM2.53132 17.4688C0.951566 15.8891 3.01483 11.2645 7.13974 7.13963C11.2647 3.01471 15.8892 0.951453 17.469 2.53121C19.0487 4.11096 16.9854 8.73551 12.8605 12.8604C8.73562 16.9853 4.11107 19.0486 2.53132 17.4688Z"
          strokeWidth="1.5"
          strokeLinecap="round"
        />
      </svg>
    ),
  },
  {
    href: "https://rmx.as/discord",
    text: "Join Discord",
    icon: (
      <svg
        xmlns="http://www.w3.org/2000/svg"
        width="24"
        height="20"
        viewBox="0 0 24 20"
        fill="none"
        className="stroke-gray-600 group-hover:stroke-current dark:stroke-gray-300"
      >
        <path
          d="M15.0686 1.25995L14.5477 1.17423L14.2913 1.63578C14.1754 1.84439 14.0545 2.08275 13.9422 2.31963C12.6461 2.16488 11.3406 2.16505 10.0445 2.32014C9.92822 2.08178 9.80478 1.84975 9.67412 1.62413L9.41449 1.17584L8.90333 1.25995C7.33547 1.51794 5.80717 1.99419 4.37748 2.66939L4.19 2.75793L4.07461 2.93019C1.23864 7.16437 0.46302 11.3053 0.838165 15.3924L0.868838 15.7266L1.13844 15.9264C2.81818 17.1714 4.68053 18.1233 6.68582 18.719L7.18892 18.8684L7.50166 18.4469C7.96179 17.8268 8.36504 17.1824 8.709 16.4944L8.71099 16.4904C10.8645 17.0471 13.128 17.0485 15.2821 16.4947C15.6261 17.1826 16.0293 17.8269 16.4892 18.4469L16.805 18.8725L17.3116 18.717C19.3056 18.105 21.1876 17.1751 22.8559 15.9238L23.1224 15.724L23.1528 15.3923C23.5873 10.6524 22.3579 6.53306 19.8947 2.90714L19.7759 2.73227L19.5833 2.64518C18.1437 1.99439 16.6386 1.51826 15.0686 1.25995ZM16.6074 10.7755L16.6074 10.7756C16.5934 11.6409 16.0212 12.1444 15.4783 12.1444C14.9297 12.1444 14.3493 11.6173 14.3493 10.7877C14.3493 9.94885 14.9378 9.41192 15.4783 9.41192C16.0471 9.41192 16.6209 9.93851 16.6074 10.7755ZM8.49373 12.1444C7.94513 12.1444 7.36471 11.6173 7.36471 10.7877C7.36471 9.94885 7.95323 9.41192 8.49373 9.41192C9.06038 9.41192 9.63892 9.93712 9.6417 10.7815C9.62517 11.6239 9.05462 12.1444 8.49373 12.1444Z"
          strokeWidth="1.5"
        />
      </svg>
    ),
  },
];


================================================
FILE: test-apps/react-router/package.json
================================================
{
  "name": "react-router-app",
  "private": true,
  "type": "module",
  "scripts": {
    "build": "react-router build",
    "dev": "react-router dev",
    "start": "react-router-serve ./build/server/index.js",
    "typecheck": "react-router typegen && tsc --build --noEmit"
  },
  "dependencies": {
    "@react-router/node": "^7.5.0",
    "@react-router/serve": "^7.5.0",
    "isbot": "^5.1.17",
    "react": "^18.3.1",
    "react-dom": "^18.3.1",
    "react-router": "^7.5.0",
    "react-hook-form": "7.55.0",
    "remix-hook-form": "*"
  },
  "devDependencies": {
    "@react-router/dev": "^7.5.0",
    "@types/node": "^20",
    "@types/react": "^18.3.12",
    "@types/react-dom": "^18.3.1",
    "autoprefixer": "^10.4.20",
    "postcss": "^8.4.49",
    "tailwindcss": "^3.4.15",
    "typescript": "^5.6.3",
    "vite": "^5.4.11",
    "vite-tsconfig-paths": "^5.1.2",
    "react-router-devtools": "^1.1.9"
  }
}

================================================
FILE: test-apps/react-router/react-router.config.ts
================================================
import type { Config } from "@react-router/dev/config";

declare module "react-router" {
  interface Future {
    unstable_middleware: true; // 👈 Enable middleware types
  }
}

export default {
  future: {
    unstable_middleware: true, // 👈 Enable middleware
  },
} satisfies Config;


================================================
FILE: test-apps/react-router/tailwind.config.ts
================================================
import type { Config } from "tailwindcss";

export default {
  content: ["./app/**/{**,.client,.server}/**/*.{js,jsx,ts,tsx}"],
  theme: {
    extend: {
      fontFamily: {
        sans: [
          '"Inter"',
          "ui-sans-serif",
          "system-ui",
          "sans-serif",
          '"Apple Color Emoji"',
          '"Segoe UI Emoji"',
          '"Segoe UI Symbol"',
          '"Noto Color Emoji"',
        ],
      },
    },
  },
  plugins: [],
} satisfies Config;


================================================
FILE: test-apps/react-router/tsconfig.json
================================================
{
  "include": [
    "**/*",
    "**/.server/**/*",
    "**/.client/**/*",
    ".react-router/types/**/*"
  ],
  "compilerOptions": {
    "lib": ["DOM", "DOM.Iterable", "ES2022"],
    "types": ["node", "vite/client"],
    "target": "ES2022",
    "module": "ES2022",
    "moduleResolution": "bundler",
    "jsx": "react-jsx",
    "rootDirs": [".", "./.react-router/types"],
    "baseUrl": ".",
    "paths": {
      "~/*": ["./app/*"]
    },
    "esModuleInterop": true,
    "forceConsistentCasingInFileNames": true,
    "isolatedModules": true,
    "noEmit": true,
    "resolveJsonModule": true,
    "skipLibCheck": true,
    "strict": true
  }
}


================================================
FILE: test-apps/react-router/vite.config.ts
================================================
import { reactRouter } from "@react-router/dev/vite";
import autoprefixer from "autoprefixer";
import { reactRouterDevTools } from "react-router-devtools";
import tailwindcss from "tailwindcss";
import { defineConfig } from "vite";
import tsconfigPaths from "vite-tsconfig-paths";

export default defineConfig({
  css: {
    postcss: {
      plugins: [tailwindcss, autoprefixer],
    },
  },
  plugins: [reactRouterDevTools(), reactRouter(), tsconfigPaths()],
});


================================================
FILE: tsconfig.json
================================================
{
  "compilerOptions": {
    /* Visit https://aka.ms/tsconfig.json to read more about this file */

    /* Projects */
    // "incremental": true,                              /* Enable incremental compilation */
    // "composite": true,                                /* Enable constraints that allow a TypeScript project to be used with project references. */
    // "tsBuildInfoFile": "./",                          /* Specify the folder for .tsbuildinfo incremental compilation files. */
    // "disableSourceOfProjectReferenceRedirect": true,  /* Disable preferring source files instead of declaration files when referencing composite projects */
    // "disableSolutionSearching": true,                 /* Opt a project out of multi-project reference checking when editing. */
    // "disableReferencedProjectLoad": true,             /* Reduce the number of projects loaded automatically by TypeScript. */

    /* Language and Environment */
    "target": "es2018" /* Set the JavaScript language version for emitted JavaScript and include compatible library declarations. */,
    "lib": [
      "dom",
      "dom.iterable",
      "esnext"
    ] /* Specify a set of bundled library declaration files that describe the target runtime environment. */,
    "jsx": "react" /* Specify what JSX code is generated. */,

    "module": "es2015" /* Specify what module code is generated. */,
    "rootDir": "./src" /* Specify the root folder within your source files. */,
    "moduleResolution": "node" /* Specify how TypeScript looks up a file from a given module specifier. */,
    "types": [
      "vitest/globals"
    ] /* Specify type package names to be included without being referenced in a source file. */,

    /* Emit */
    "declaration": true /* Generate .d.ts files from TypeScript and JavaScript files in your project. */,
    "outDir": "./dist" /* Specify an output folder for all emitted files. */,
    "esModuleInterop": true /* Emit additional JavaScript to ease support for importing CommonJS modules. This enables `allowSyntheticDefaultImports` for type compatibility. */,
    // "preserveSymlinks": true,                         /* Disable resolving symlinks to their realpath. This correlates to the same flag in node. */
    "forceConsistentCasingInFileNames": true /* Ensure that casing is correct in imports. */,

    /* Type Checking */
    "strict": true /* Enable all strict type-checking options. */,
    "skipLibCheck": true /* Skip type checking all .d.ts files. */,
    "verbatimModuleSyntax": true /* Require `type` prefix for type-only imports */
  },
  "exclude": [
    "./bin/**/*.test.ts",
    "./dist/**/*",
    "./bin/mocks/**/*",
    "./*.config.ts",
    "./src/testing-app/**",
    "./test-apps"
  ]
}


================================================
FILE: vitest.config.ts
================================================
import { defineConfig } from "vitest/config";

export default defineConfig({
  test: {
    globals: true,
    environment: "happy-dom",
  },
});
Download .txt
gitextract__hdis4zo/

├── .eslintrc
├── .github/
│   ├── FUNDING.yml
│   └── workflows/
│       ├── publish-commit.yaml
│       ├── publish.yaml
│       └── validate.yaml
├── .gitignore
├── .prettierignore
├── .prettierrc
├── CODE_OF_CONDUCT.md
├── LICENSE
├── README.md
├── SECURITY.MD
├── package.json
├── pull_request_template.md
├── src/
│   ├── hook/
│   │   ├── index.test.tsx
│   │   └── index.tsx
│   ├── index.ts
│   ├── middleware/
│   │   └── index.ts
│   └── utilities/
│       ├── index.test.ts
│       └── index.ts
├── test-apps/
│   └── react-router/
│       ├── .dockerignore
│       ├── .gitignore
│       ├── Dockerfile
│       ├── Dockerfile.bun
│       ├── Dockerfile.pnpm
│       ├── README.md
│       ├── app/
│       │   ├── app.css
│       │   ├── root.tsx
│       │   ├── routes/
│       │   │   └── home.tsx
│       │   ├── routes.ts
│       │   └── welcome/
│       │       └── welcome.tsx
│       ├── package.json
│       ├── react-router.config.ts
│       ├── tailwind.config.ts
│       ├── tsconfig.json
│       └── vite.config.ts
├── tsconfig.json
└── vitest.config.ts
Download .txt
SYMBOL INDEX (27 symbols across 7 files)

FILE: src/hook/index.tsx
  type SubmitFunctionOptions (line 38) | type SubmitFunctionOptions = Parameters<SubmitFunction>[1];
  type UseRemixFormOptions (line 40) | interface UseRemixFormOptions<
  method isDirty (line 148) | get isDirty() {
  method isLoading (line 151) | get isLoading() {
  method isSubmitted (line 154) | get isSubmitted() {
  method isSubmitSuccessful (line 157) | get isSubmitSuccessful() {
  method isSubmitting (line 160) | get isSubmitting() {
  method isValidating (line 163) | get isValidating() {
  method isValid (line 166) | get isValid() {
  method disabled (line 169) | get disabled() {
  method submitCount (line 172) | get submitCount() {
  method defaultValues (line 175) | get defaultValues() {
  method dirtyFields (line 178) | get dirtyFields() {
  method touchedFields (line 181) | get touchedFields() {
  method validatingFields (line 184) | get validatingFields() {
  method errors (line 187) | get errors() {
  type UseRemixFormReturn (line 262) | type UseRemixFormReturn<
  type RemixFormProviderProps (line 272) | interface RemixFormProviderProps<

FILE: src/middleware/index.ts
  function unstable_extractFormDataMiddleware (line 15) | function unstable_extractFormDataMiddleware({

FILE: src/utilities/index.ts
  type ReturnData (line 108) | type ReturnData<

FILE: test-apps/react-router/app/root.tsx
  function Layout (line 31) | function Layout({ children }: { children: React.ReactNode }) {
  function App (line 49) | function App() {
  function ErrorBoundary (line 55) | function ErrorBoundary({ error }: Route.ErrorBoundaryProps) {

FILE: test-apps/react-router/app/routes/home.tsx
  type FormData (line 39) | type FormData = z.infer<typeof FormDataZodSchema>;
  function Index (line 65) | function Index() {

FILE: test-apps/react-router/app/welcome/welcome.tsx
  function Welcome (line 4) | function Welcome() {

FILE: test-apps/react-router/react-router.config.ts
  type Future (line 4) | interface Future {
Condensed preview — 38 files, each showing path, character count, and a content snippet. Download the .json file or copy for the full structured content (120K chars).
[
  {
    "path": ".eslintrc",
    "chars": 597,
    "preview": "{\n  \"extends\": [\n    \"eslint:recommended\",\n    \"plugin:@typescript-eslint/recommended\",\n    \"plugin:react/recommended\",\n"
  },
  {
    "path": ".github/FUNDING.yml",
    "chars": 738,
    "preview": "# These are supported funding model platforms\n\ngithub: [AlemTuzlak]\npatreon: # Replace with a single Patreon username\nop"
  },
  {
    "path": ".github/workflows/publish-commit.yaml",
    "chars": 437,
    "preview": "name: 🚀 pkg-pr-new\non: [push, pull_request]\n\njobs:\n  build:\n    runs-on: ubuntu-latest\n\n    steps:\n      - name: Checkou"
  },
  {
    "path": ".github/workflows/publish.yaml",
    "chars": 470,
    "preview": "name: Publish Package to npmjs\non:\n  release:\n    types: [published]\n  workflow_dispatch:\njobs:\n  npm-publish:\n    runs-"
  },
  {
    "path": ".github/workflows/validate.yaml",
    "chars": 1867,
    "preview": "name: 🚀 Validation Pipeline\nconcurrency:\n  group: ${{ github.repository }}-${{ github.workflow }}-${{ github.ref }}\n  ca"
  },
  {
    "path": ".gitignore",
    "chars": 1648,
    "preview": "# Logs\nlogs\n*.log\nnpm-debug.log*\nyarn-debug.log*\nyarn-error.log*\nlerna-debug.log*\n\n# Diagnostic reports (https://nodejs."
  },
  {
    "path": ".prettierignore",
    "chars": 19,
    "preview": "node_modules\n/build"
  },
  {
    "path": ".prettierrc",
    "chars": 6,
    "preview": "{\n  \n}"
  },
  {
    "path": "CODE_OF_CONDUCT.md",
    "chars": 5201,
    "preview": "# Contributor Covenant Code of Conduct\n\n## Our Pledge\n\nWe as members, contributors, and leaders pledge to make participa"
  },
  {
    "path": "LICENSE",
    "chars": 1067,
    "preview": "MIT License\n\nCopyright (c) 2023 Code Forge\n\nPermission is hereby granted, free of charge, to any person obtaining a copy"
  },
  {
    "path": "README.md",
    "chars": 21210,
    "preview": "# remix-hook-form\n\n![GitHub Repo stars](https://img.shields.io/github/stars/forge-42/remix-hook-form?style=social)\n![npm"
  },
  {
    "path": "SECURITY.MD",
    "chars": 277,
    "preview": "# Security Policy\n\n## Supported Versions\n\n| Version | Supported          |\n| ------- | ------------------ | \n| 1.0.x   |"
  },
  {
    "path": "package.json",
    "chars": 3328,
    "preview": "{\n  \"name\": \"remix-hook-form\",\n  \"version\": \"7.1.1\",\n  \"description\": \"Utility wrapper around react-hook-form for use wi"
  },
  {
    "path": "pull_request_template.md",
    "chars": 1382,
    "preview": "# Description\n\nPlease include a summary of the change and which issue is fixed. Please also include relevant motivation "
  },
  {
    "path": "src/hook/index.test.tsx",
    "chars": 13399,
    "preview": "import {\n  act,\n  cleanup,\n  fireEvent,\n  render,\n  renderHook,\n  waitFor,\n} from \"@testing-library/react\";\nimport React"
  },
  {
    "path": "src/hook/index.tsx",
    "chars": 9791,
    "preview": "import React, {\n  type FormEvent,\n  type ReactNode,\n  useEffect,\n  useMemo,\n  useState,\n} from \"react\";\nimport {\n  type "
  },
  {
    "path": "src/index.ts",
    "chars": 236,
    "preview": "export {\n  parseFormData,\n  createFormData,\n  getValidatedFormData,\n  validateFormData,\n  getFormDataFromSearchParams,\n "
  },
  {
    "path": "src/middleware/index.ts",
    "chars": 1842,
    "preview": "import type { FieldValues, Resolver } from \"react-hook-form\";\nimport {\n  // data as dataFn,\n  type unstable_MiddlewareFu"
  },
  {
    "path": "src/utilities/index.test.ts",
    "chars": 19192,
    "preview": "import { array, boolean, coerce, date, number, object, string } from \"zod\";\nimport {\n  createFormData,\n  generateFormDat"
  },
  {
    "path": "src/utilities/index.ts",
    "chars": 8931,
    "preview": "import type { FieldErrors, FieldValues, Resolver } from \"react-hook-form\";\n\nconst tryParseJSON = (value: string | File |"
  },
  {
    "path": "test-apps/react-router/.dockerignore",
    "chars": 42,
    "preview": ".react-router\nbuild\nnode_modules\nREADME.md"
  },
  {
    "path": "test-apps/react-router/.gitignore",
    "chars": 76,
    "preview": ".env\n!.env.example\n.DS_Store\n.react-router\nbuild\nnode_modules\n*.tsbuildinfo\n"
  },
  {
    "path": "test-apps/react-router/Dockerfile",
    "chars": 599,
    "preview": "FROM node:20-alpine as development-dependencies-env\nCOPY . /app\nWORKDIR /app\nRUN npm ci\n\nFROM node:20-alpine as producti"
  },
  {
    "path": "test-apps/react-router/Dockerfile.bun",
    "chars": 705,
    "preview": "FROM oven/bun:1 as dependencies-env\nCOPY . /app\n\nFROM dependencies-env as development-dependencies-env\nCOPY ./package.js"
  },
  {
    "path": "test-apps/react-router/Dockerfile.pnpm",
    "chars": 752,
    "preview": "FROM node:20-alpine as dependencies-env\nRUN npm i -g pnpm\nCOPY . /app\n\nFROM dependencies-env as development-dependencies"
  },
  {
    "path": "test-apps/react-router/README.md",
    "chars": 1901,
    "preview": "# Welcome to React Router!\n\nA modern, production-ready template for building full-stack React applications using React R"
  },
  {
    "path": "test-apps/react-router/app/app.css",
    "chars": 180,
    "preview": "@tailwind base;\n@tailwind components;\n@tailwind utilities;\n\nhtml,\nbody {\n  @apply bg-white dark:bg-gray-950;\n\n  @media ("
  },
  {
    "path": "test-apps/react-router/app/root.tsx",
    "chars": 2043,
    "preview": "import {\n  Links,\n  Meta,\n  Outlet,\n  Scripts,\n  ScrollRestoration,\n  isRouteErrorResponse,\n} from \"react-router\";\n\nimpo"
  },
  {
    "path": "test-apps/react-router/app/routes/home.tsx",
    "chars": 4459,
    "preview": "import { zodResolver } from \"@hookform/resolvers/zod\";\nimport { type ClientActionFunctionArgs, Form, useFetcher } from \""
  },
  {
    "path": "test-apps/react-router/app/routes.ts",
    "chars": 134,
    "preview": "import { type RouteConfig, index } from \"@react-router/dev/routes\";\n\nexport default [index(\"routes/home.tsx\")] satisfies"
  },
  {
    "path": "test-apps/react-router/app/welcome/welcome.tsx",
    "chars": 4199,
    "preview": "import logoDark from \"./logo-dark.svg\";\nimport logoLight from \"./logo-light.svg\";\n\nexport function Welcome() {\n  return "
  },
  {
    "path": "test-apps/react-router/package.json",
    "chars": 914,
    "preview": "{\n  \"name\": \"react-router-app\",\n  \"private\": true,\n  \"type\": \"module\",\n  \"scripts\": {\n    \"build\": \"react-router build\","
  },
  {
    "path": "test-apps/react-router/react-router.config.ts",
    "chars": 285,
    "preview": "import type { Config } from \"@react-router/dev/config\";\n\ndeclare module \"react-router\" {\n  interface Future {\n    unstab"
  },
  {
    "path": "test-apps/react-router/tailwind.config.ts",
    "chars": 477,
    "preview": "import type { Config } from \"tailwindcss\";\n\nexport default {\n  content: [\"./app/**/{**,.client,.server}/**/*.{js,jsx,ts,"
  },
  {
    "path": "test-apps/react-router/tsconfig.json",
    "chars": 646,
    "preview": "{\n  \"include\": [\n    \"**/*\",\n    \"**/.server/**/*\",\n    \"**/.client/**/*\",\n    \".react-router/types/**/*\"\n  ],\n  \"compil"
  },
  {
    "path": "test-apps/react-router/vite.config.ts",
    "chars": 464,
    "preview": "import { reactRouter } from \"@react-router/dev/vite\";\nimport autoprefixer from \"autoprefixer\";\nimport { reactRouterDevTo"
  },
  {
    "path": "tsconfig.json",
    "chars": 2740,
    "preview": "{\n  \"compilerOptions\": {\n    /* Visit https://aka.ms/tsconfig.json to read more about this file */\n\n    /* Projects */\n "
  },
  {
    "path": "vitest.config.ts",
    "chars": 145,
    "preview": "import { defineConfig } from \"vitest/config\";\n\nexport default defineConfig({\n  test: {\n    globals: true,\n    environmen"
  }
]

About this extraction

This page contains the full source code of the Code-Forge-Net/remix-hook-form GitHub repository, extracted and formatted as plain text for AI agents and large language models (LLMs). The extraction includes 38 files (109.8 KB), approximately 28.9k tokens, and a symbol index with 27 extracted functions, classes, methods, constants, and types. Use this with OpenClaw, Claude, ChatGPT, Cursor, Windsurf, or any other AI tool that accepts text input. You can copy the full output to your clipboard or download it as a .txt file.

Extracted by GitExtract — free GitHub repo to text converter for AI. Built by Nikandr Surkov.

Copied to clipboard!