shadcn-ui / ui

Beautifully designed components that you can copy and paste into your apps. Accessible. Customizable. Open Source.
https://ui.shadcn.com
MIT License
69.36k stars 4.13k forks source link

new React Hook Form makes my app crash #408

Closed maxentr closed 1 year ago

maxentr commented 1 year ago

Hello,

I tried to use the new react hook form with the example given in the doc but the app crashed.

Here is the code used in the app:

"use client";

import { Button } from "@/components/ui/button";
import {
  Form,
  FormControl,
  FormDescription,
  FormField,
  FormItem,
  FormLabel,
  FormMessage,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { zodResolver } from "@hookform/resolvers/zod";
import { useForm } from "react-hook-form";
import { z } from "zod";

const formSchema = z.object({
  username: z.string().min(2, {
    message: "Name must be at least 2 characters.",
  }),
});

export default async function Page() {
  const form = useForm<z.infer<typeof formSchema>>({
    resolver: zodResolver(formSchema),
  });

  // 2. Define a submit handler.
  function onSubmit(values: z.infer<typeof formSchema>) {
    // Do something with the form values.
    // ✅ This will be type-safe and validated.
    console.log(values);
  }

  return (
    <div className="p-16 flex-1 flex flex-col">
      <h2 className="text-2xl font-bold mb-4">New group</h2>
      <div className="flex-1 flex flex-col gap-2">
        <Form {...form}>
          <form onSubmit={form.handleSubmit(onSubmit)}>
            <FormField
              control={form.control}
              name="username"
              render={({ field }) => (
                <FormItem>
                  <FormLabel>Name</FormLabel>
                  <FormControl>
                    <Input placeholder="username" {...field} />
                  </FormControl>
                  <FormDescription>
                    This is your public display name.
                  </FormDescription>
                  <FormMessage />
                </FormItem>
              )}
            />
            <Button type="submit">Submit</Button>
          </form>
        </Form>
      </div>
    </div>
  );
}

The form.tsx file:

import * as React from "react"
import * as LabelPrimitive from "@radix-ui/react-label"
import { Slot } from "@radix-ui/react-slot"
import {
  Controller,
  ControllerProps,
  FieldPath,
  FieldValues,
  FormProvider,
  useFormContext,
} from "react-hook-form"

import { cn } from "@/lib/utils"
import { Label } from "@/components/ui/label"

const Form = FormProvider

type FormFieldContextValue<
  TFieldValues extends FieldValues = FieldValues,
  TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>
> = {
  name: TName
}

const FormFieldContext = React.createContext<FormFieldContextValue>(
  {} as FormFieldContextValue
)

const FormField = <
  TFieldValues extends FieldValues = FieldValues,
  TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>
>({
  ...props
}: ControllerProps<TFieldValues, TName>) => {
  return (
    <FormFieldContext.Provider value={{ name: props.name }}>
      <Controller {...props} />
    </FormFieldContext.Provider>
  )
}

const useFormField = () => {
  const fieldContext = React.useContext(FormFieldContext)
  const itemContext = React.useContext(FormItemContext)
  const { getFieldState, formState } = useFormContext()

  const fieldState = getFieldState(fieldContext.name, formState)

  if (!fieldContext) {
    throw new Error("useFormField should be used within <FormField>")
  }

  const { id } = itemContext

  return {
    id,
    name: fieldContext.name,
    formItemId: `${id}-form-item`,
    formDescriptionId: `${id}-form-item-description`,
    formMessageId: `${id}-form-item-message`,
    ...fieldState,
  }
}

type FormItemContextValue = {
  id: string
}

const FormItemContext = React.createContext<FormItemContextValue>(
  {} as FormItemContextValue
)

const FormItem = React.forwardRef<
  HTMLDivElement,
  React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => {
  const id = React.useId()

  return (
    <FormItemContext.Provider value={{ id }}>
      <div ref={ref} className={cn("space-y-2", className)} {...props} />
    </FormItemContext.Provider>
  )
})
FormItem.displayName = "FormItem"

const FormLabel = React.forwardRef<
  React.ElementRef<typeof LabelPrimitive.Root>,
  React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root>
>(({ className, ...props }, ref) => {
  const { error, formItemId } = useFormField()

  return (
    <Label
      ref={ref}
      className={cn(error && "text-destructive", className)}
      htmlFor={formItemId}
      {...props}
    />
  )
})
FormLabel.displayName = "FormLabel"

const FormControl = React.forwardRef<
  React.ElementRef<typeof Slot>,
  React.ComponentPropsWithoutRef<typeof Slot>
>(({ ...props }, ref) => {
  const { error, formItemId, formDescriptionId, formMessageId } = useFormField()

  return (
    <Slot
      ref={ref}
      id={formItemId}
      aria-describedby={
        !error
          ? `${formDescriptionId}`
          : `${formDescriptionId} ${formMessageId}`
      }
      aria-invalid={!!error}
      {...props}
    />
  )
})
FormControl.displayName = "FormControl"

const FormDescription = React.forwardRef<
  HTMLParagraphElement,
  React.HTMLAttributes<HTMLParagraphElement>
>(({ className, ...props }, ref) => {
  const { formDescriptionId } = useFormField()

  return (
    <p
      ref={ref}
      id={formDescriptionId}
      className={cn("text-sm text-muted-foreground", className)}
      {...props}
    />
  )
})
FormDescription.displayName = "FormDescription"

const FormMessage = React.forwardRef<
  HTMLParagraphElement,
  React.HTMLAttributes<HTMLParagraphElement>
>(({ className, children, ...props }, ref) => {
  const { error, formMessageId } = useFormField()
  const body = error ? String(error?.message) : children

  if (!body) {
    return null
  }

  return (
    <p
      ref={ref}
      id={formMessageId}
      className={cn("text-sm font-medium text-destructive", className)}
      {...props}
    >
      {body}
    </p>
  )
})
FormMessage.displayName = "FormMessage"

export {
  useFormField,
  Form,
  FormItem,
  FormLabel,
  FormControl,
  FormDescription,
  FormMessage,
  FormField,
}

My package.json's dependencies:

{
  "name": "web-app",
  "version": "0.1.0",
  "private": true,
  "scripts": {
    "dev": "next dev",
    "build": "next build",
    "start": "next start",
    "lint": "next lint"
  },
  "dependencies": {
    "@hookform/resolvers": "^3.1.0",
    "@radix-ui/react-dialog": "^1.0.3",
    "@radix-ui/react-label": "^2.0.1",
    "@radix-ui/react-separator": "^1.0.2",
    "@radix-ui/react-slot": "^1.0.1",
    "@radix-ui/react-toast": "^1.1.3",
    "@types/node": "20.1.7",
    "@types/react": "18.2.6",
    "@types/react-dom": "18.2.4",
    "autoprefixer": "10.4.14",
    "axios": "^1.4.0",
    "class-variance-authority": "^0.6.0",
    "clsx": "^1.2.1",
    "eslint": "8.40.0",
    "eslint-config-next": "13.4.2",
    "lucide-react": "^0.217.0",
    "next": "13.4.3",
    "postcss": "8.4.23",
    "react": "18.2.0",
    "react-dom": "18.2.0",
    "react-hook-form": "^7.43.9",
    "tailwind-merge": "^1.12.0",
    "tailwindcss": "3.3.2",
    "tailwindcss-animate": "^1.0.5",
    "typescript": "5.0.4",
    "zod": "^3.21.4"
  }
}

After investigating, I found that the <FormItem> component had ref a null. I don't know if it's me who missed something or if it's a real bug but in case it is a bug I prefer to tell.

Moshyfawn commented 1 year ago

Ooof, that's a ton of plain code. Do you mind posting a reproduction sandbox instead? It can help us debug the issue a lot faster.

..or at least the reproduction steps 😉

Moshyfawn commented 1 year ago

Alright, from what I've gathered, it appears that your app is crashing due to a Zod exception. The resolver in your application is throwing the following error:

[
  {
    "code": "invalid_type",
    "expected": "string",
    "received": "undefined",
    "path": ["username"],
    "message": "Required"
  }
]

This exception occurs because the username input field does not have a default value and is therefore undefined which doesn't pass your from Zod validation. I assume you attempted to submit the form without modifying the input values. I recommend providing a default value for the input, such as an empty string.

That said, I'll look into why it's affecting your whole app by yielding an uncaught exception.

Moshyfawn commented 1 year ago

Maybe not, actually.. K, a repro is needed 😋

maxentr commented 1 year ago

Yeah my bad for the code I didn’t think about a repro sandbox in first. Here it is: https://codesandbox.io/p/sandbox/thirsty-http-kbgkx6?file=%2FREADME.md%3A14%2C1.

shadcn commented 1 year ago

@Maxentr I don't have access to codesandbox right now but could it be related to this? https://github.com/shadcn/ui/issues/410#issuecomment-1556705052

srejitk commented 1 year ago

Can confirm the issue @shadcn @Maxentr and yes, the issue was resolve once I provided default values to the columns.

maxentr commented 1 year ago

I updated the codesandbox with the default values. The crash is still there. By the way the crash is more like the tab freeze and make the whole browser lag

Moshyfawn commented 1 year ago

I'm still trying to get a hold of the issue, but it doesn't seem like it's input related. At least, I can reproduce it by commenting the whole FormField component out of the form and refreshing the page: the same "frozen page" page effect.

As soon as I introduce the shadcn/ui Form component, the page freezes 🧐

Moshyfawn commented 1 year ago

Can it be something React Context related in the context of RSC? I saw multiple thread about it on Twitter, but never bothered to look at it in detail

Moshyfawn commented 1 year ago

K, so, I think I got it (kinda).

I'm pretty sure it's React & RSC related and potentially something to do with how Next.js treats page.js files (routes).

Extracting the form into it's own ProfileForm.js with "use client" directive doesn't yield the same infinite re-renders behaviour and works as expected.

page.js

import { ProfileForm } from './profile-form'

export default async function Page() {
  return (
    <div className='p-16 flex-1 flex flex-col'>
      <h2 className='text-2xl font-bold mb-4'>Form</h2>
      <div className='flex-1 flex flex-col gap-2'>
        <ProfileForm />
      </div>
    </div>
  )
}

profile-form.js

"use client"

const formSchema = z.object({
  username: z.string().min(2, {
    message: 'Name must be at least 2 characters.'
  })
})

export function ProfileForm() {
  const form = useForm<z.infer<typeof formSchema>>({
    resolver: zodResolver(formSchema),
    defaultValues: {
      username: ''
    }
  })

  return (
    <Form {...form}>
      <form onSubmit={form.handleSubmit((data) => console.log(data))}>
        <FormField
          control={form.control}
          name='username'
          render={({ field }) => (
            <FormItem>
              <FormLabel>Name</FormLabel>
              <FormControl>
                <Input placeholder='username' {...field} />
              </FormControl>
              <FormDescription>This is your public display name.</FormDescription>
              <FormMessage />
            </FormItem>
          )}
        />
        <Button type='submit'>Submit</Button>
      </form>
    </Form>
  )
}

P.S. .js extension is here to simply point out that it's a different file. You can write your .ts or .tsx files instead.

Moshyfawn commented 1 year ago

Here a working codesandbox fork of the OG codesandbox provided by @Maxentr

maxentr commented 1 year ago

K, so, I think I got it (kinda).

I'm pretty sure it's React & RSC related and potentially something to do with how Next.js treats page.js files (routes).

Extracting the form into it's own ProfileForm.js with "use client" directive doesn't yield the same infinite re-renders behaviour and works as expected.

Thank you for finding the error. From what I understand I should avoid using "use client" in the page.tsx file by default.