edmundhung / conform

A type-safe form validation library utilizing web fundamentals to progressively enhance HTML Forms with full support for server frameworks like Remix and Next.js.
https://conform.guide
MIT License
1.98k stars 107 forks source link

Unexpected navigation can occur with Remix useFetcher and resetForm: true #730

Open aust1nz opened 2 months ago

aust1nz commented 2 months ago

Describe the bug and the expected behavior

I've noticed some funny interplay between using Remix's useFetcher and conform's resetForm option, specifically when the useFetcher hits an action from another route. I've created a sandbox that shows this issue here:

https://codesandbox.io/p/devbox/interesting-hamilton-xt5pwn

Specifically, if the user fills out a name and hits enter to submit the form, they're brought to the JSON response that the action returns. Interestingly, if the user clicks the submit button, the form works as expected.

Conform version

1.1.5

Steps to Reproduce the Bug or Issue

https://codesandbox.io/p/devbox/interesting-hamilton-xt5pwn

Alternatively, create a basic Remix app with zod, @conform-to/react, and @conform-to/zod installed.

Update routes/index.tsx

import { getFormProps, getInputProps, useForm } from "@conform-to/react";
import { getZodConstraint, parseWithZod } from "@conform-to/zod";
import { json, type MetaFunction } from "@remix-run/node";
import { useActionData, useLoaderData } from "@remix-run/react";
import { useFetcher } from "react-router-dom";
import { z } from "zod";
import { action as createUserAction } from "./create-user";

export const users = [{ name: "Austin" }];
const schema = z.object({
  name: z.string().min(2),
});

export const meta: MetaFunction = () => {
  return [
    { title: "New Remix App" },
    { name: "description", content: "Welcome to Remix!" },
  ];
};

export async function loader() {
  return json(users);
}

export default function Index() {
  const users = useLoaderData<typeof loader>();
  const fetcher = useFetcher<typeof createUserAction>();
  const lastResult = useActionData<typeof createUserAction>();
  const [form, fields] = useForm({
    lastResult: fetcher.state === "idle" ? fetcher?.data : null,
    constraint: getZodConstraint(schema),
    shouldValidate: "onBlur",
    shouldRevalidate: "onInput",
    onValidate({ formData }) {
      return parseWithZod(formData, { schema });
    },
    defaultValue: {
      name: "",
    },
  });

  return (
    <div style={{ fontFamily: "system-ui, sans-serif", lineHeight: "1.8" }}>
      <fetcher.Form method="post" action="/create-user" {...getFormProps(form)}>
        <p>
          This example uses a Remix fetcher to submit data to another route. If
          you click "Submit", you'll see things work as expected. The route is
          accessed, and returns a submission.result that clears the input.
        </p>
        <p>
          But if you fill in a value in the input and hit Enter, you're
          unexpectedly navigated to the remote route, which returns JSON.
        </p>
        <label>User Name</label>
        <br />
        <input {...getInputProps(fields.name, { type: "text" })} />
        {fields.name.errors && <div>{fields.name.errors[0]}</div>}
        <br /> <input type="submit" value="Submit" />
      </fetcher.Form>
      {users.map((user, index) => (
        <div key={index}>{user.name}</div>
      ))}
    </div>
  );
}

Create routes/create-user.tsx

import { parseWithZod } from "@conform-to/zod";
import { ActionFunctionArgs } from "@remix-run/node";
import { users } from "./_index";
import { z } from "zod";

const schema = z.object({
  name: z.string().min(4),
});

export async function action({ request }: ActionFunctionArgs) {
  const formData = await request.formData();
  const submission = parseWithZod(formData, { schema });
  if (submission.status !== "success") {
    return submission.reply();
  }

  users.push({
    name: submission.value.name,
  });
  return submission.reply({
    resetForm: true,
  });
}

What browsers are you seeing the problem on?

Chrome

Screenshots or Videos

No response

Additional context

I suppose it's possible this is a Remix issue, but seems to specifically happen when the submission.response({ resetForm: true }) option is configured.

edmundhung commented 2 months ago

Thanks for the codesandbox! I can reproduce the issue without Conform here.

Here is what happened:

Conform manages all uncontrolled inputs through a key. When form reset happens, it forces react to unmount all inputs by updating the key passed to them. If your cursor was focusing on one of the inputs, it will then lose the focus and trigger form validation by making a form submission with the validate intent. Normally, Remix will just capture the submit event and trigger a fetch request for you. But it becomes a document request in this case...

It's hard to tell what's really went wrong here because we are indeed abusing the keys right now 😅

Luckily, we are getting rid of the keys soon! I am planning to get #729 released in 1.2.0. As we will no longer re-mount the inputs on form reset, it will not try to validate the form again and so the issue should be gone.

edmundhung commented 1 month ago

This is now working properly with v1.2.1. Thanks again!

aust1nz commented 1 month ago

Awesome, thanks for the update!