radix-ui / primitives

Radix Primitives is an open-source UI component library for building high-quality, accessible design systems and web apps. Maintained by @workos.
https://radix-ui.com/primitives
MIT License
15.88k stars 823 forks source link

[Portal] Hydration failure in SSR when initially rendered #1386

Closed andy-hook closed 5 months ago

andy-hook commented 2 years ago

This manifests itself in Dialog and AlertDialog when using Portal with DefaultOpen in SSR env.

https://codesandbox.io/s/friendly-morning-3ny0r5?file=/pages/index.tsx

benoitgrelard commented 2 years ago

It seems the original portal had some code to bail out of ssr. Do we need similar for the new one?

andy-hook commented 2 years ago

It seems the original portal had some code to bail out of ssr.

Do you mean this?

Do we need similar for the new one?

I think this is slightly different as hydration expects matching server / client. If we want to continue injecting into body by default we’ll probably have to add another render and do it as another pass on client only.

laozhu commented 2 years ago

The same issue here when working with next.js, any plan to fix this? Now my workaround is set the open to false default, then set it true in useEffect when page has mounted.

andy-hook commented 2 years ago

Another solution is to pass a wrapper node to container

gabrielmlinassi commented 2 years ago

I'd like to know if this hydration error also relates to this issue. I'm conditionally rendering a Dialog or Button based on viewport size. If it's a small window, I'd like to redirect the user to the registration page, otherwise, show a register dialog. This is a contrived example showing the error: https://stackblitz.com/edit/nextjs-hahx6j?file=pages/index.js

Error: Warning: Text content did not match. Server: "redirect to page.." Client: "open me dialog"

andy-hook commented 2 years ago

I'd like to know if this hydration error also relates to this issue. I'm conditionally rendering a Dialog or Button based on viewport size.

Not directly, no. The server has no concept of screen media size until it hits the client which is why is falls into the false block on server and hydrates true on desktop which reports a mismatch.

ibnumusyaffa commented 1 year ago

i think this issue affect all user that use third party animation like framer motion

workaround iam using, while waiting for this to be fixed

function Content({ children }) {
  const { open } = usePopover();

  //workaround for radix bug
  const [_, forceRender] = useState(0);
  const containerRef = useRef(null);

  useEffect(() => {
    containerRef.current = document.body;
    forceRender((prev) => prev + 1);
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, []);

  return (
    <AnimatePresence>
      {open ? (
        <PopoverPrimitive.Portal
          forceMount
          container={containerRef.current}
        >
          <PopoverPrimitive.Content asChild forceMount>
              ......
          </PopoverPrimitive.Content>
        </PopoverPrimitive.Portal>
      ) : null}
    </AnimatePresence>
  );
}
reorx commented 1 year ago

FYI headlessui's portal works out of the box in NextJS SSR, curious how they did it, but the code is too complicated for me to understand https://github.com/tailwindlabs/headlessui/blob/main/packages/%40headlessui-react/src/components/portal/portal.tsx

linkjane commented 1 year ago

Another solution is to pass a wrapper node to container

Could you please write a demo here, I really don't understand how to pass a wrapper node to container

Nhollas commented 1 year ago

Bruh why has this not been fixed :(

ConnorCallison commented 1 year ago

Bump +1️⃣

adomaitisc commented 1 year ago

Hey, I had this issue just now and I fixed by making it a client component + useState + useEffect.

I am using next 13.4.3, with @radix-ui/react-alert-dialog 1.0.4

Here is a working version using the 'open' prop to AlertDialog's root.

"use client";

import {
  AlertDialog,
  AlertDialogAction,
  AlertDialogCancel,
  AlertDialogContent,
  AlertDialogDescription,
  AlertDialogFooter,
  AlertDialogHeader,
  AlertDialogTitle,
  AlertDialogTrigger,
} from "@/components/ui/alert-dialog";
import { Button } from "@/components/ui/button";
import React from "react";

export function StatusAlertDialog() {
  const [isOpen, setIsOpen] = React.useState(false);

  React.useEffect(() => {
    setIsOpen(true);
  }, []);
  return (
    <AlertDialog open={isOpen}>
      <AlertDialogTrigger asChild>
        <Button variant="outline">Show Dialog</Button>
      </AlertDialogTrigger>
      <AlertDialogContent>
        <AlertDialogHeader>
          <AlertDialogTitle>Are you absolutely sure?</AlertDialogTitle>
          <AlertDialogDescription>
            This action cannot be undone. This will permanently delete your
            account and remove your data from our servers.
          </AlertDialogDescription>
        </AlertDialogHeader>
        <AlertDialogFooter>
          <AlertDialogCancel>Cancel</AlertDialogCancel>
          <AlertDialogAction>Continue</AlertDialogAction>
        </AlertDialogFooter>
      </AlertDialogContent>
    </AlertDialog>
  );
}
pharaok commented 1 year ago

Toast viewport is broken as well and I can't seem to find a workaround for it, no container prop and it doesn't matter if there's no toasts open, so no useEffect hack either.

the-bayer commented 1 year ago

Hey, I had this issue just now and I fixed by making it a client component + useState + useEffect.

I am using next 13.4.3, with @radix-ui/react-alert-dialog 1.0.4

Here is a working version using the 'open' prop to AlertDialog's root.

"use client";

import {
  AlertDialog,
  AlertDialogAction,
  AlertDialogCancel,
  AlertDialogContent,
  AlertDialogDescription,
  AlertDialogFooter,
  AlertDialogHeader,
  AlertDialogTitle,
  AlertDialogTrigger,
} from "@/components/ui/alert-dialog";
import { Button } from "@/components/ui/button";
import React from "react";

export function StatusAlertDialog() {
  const [isOpen, setIsOpen] = React.useState(false);

  React.useEffect(() => {
    setIsOpen(true);
  }, []);
  return (
    <AlertDialog open={isOpen}>
      <AlertDialogTrigger asChild>
        <Button variant="outline">Show Dialog</Button>
      </AlertDialogTrigger>
      <AlertDialogContent>
        <AlertDialogHeader>
          <AlertDialogTitle>Are you absolutely sure?</AlertDialogTitle>
          <AlertDialogDescription>
            This action cannot be undone. This will permanently delete your
            account and remove your data from our servers.
          </AlertDialogDescription>
        </AlertDialogHeader>
        <AlertDialogFooter>
          <AlertDialogCancel>Cancel</AlertDialogCancel>
          <AlertDialogAction>Continue</AlertDialogAction>
        </AlertDialogFooter>
      </AlertDialogContent>
    </AlertDialog>
  );
}

Just ran into this issue as well, using this fix

DerJacques commented 1 year ago

We're running into this as well. Building on top of @adomaitisc's solution, here's a component that only replaces the Root component (in this case for the Dialog, but could be AlertDialog just as well):

import type { DialogContentProps } from '@radix-ui/react-dialog'
import * as RadixDialog from '@radix-ui/react-dialog'

const Root: React.FC<RadixDialog.DialogProps> = (props) => {
  // This terrible hack is needed to prevent hydration errors.
  // The Radix Dialog is not rendered correctly server side, so we need to prevent it from rendering until the client side hydration is complete (and `useEffect` is run).
  // The issue is reported here: https://github.com/radix-ui/primitives/issues/1386
  const [open, setOpen] = useState<boolean | undefined>(props.open === undefined ? undefined : false)

  useEffect(() => {
    setOpen(props.open)
  }, [props.open, setOpen])

  return <RadixDialog.Root {...props} open={open} />
}

You can then use this custom Root instead of the default one.

Oudwins commented 1 year ago

This also creates some big problems when using astro & SSG with any radix-ui component.

Can we get a permanent fix for this that allows components to be hydrated correctly?

see issue I opened which got closed -> https://github.com/radix-ui/primitives/issues/2164

with-key commented 1 year ago

I just did this to fix the current issue.

export const Panel = (props: PropsWithChildren<PenelProps>) => {
  const [isClient, setIsClient] = useState(false);

  const { children } = props;
  const { onToggleDropdown, isOpen } = useDropdown();

  useEffect(() => {
    setIsClient(true);
  }, []);

  if (!isClient) return null;

  return isOpen ? (
    <Portal.Root>
      <DefaultStylePanel>{children}</DefaultStylePanel>
    </Portal.Root>
  ) : null;
};
intagaming commented 1 year ago

TL;DR: Leaky abstraction.

I think it is important to realize right now that we are pursuing what we might not want in the end. Hydration, in my opinion, is the non-essential ingredient for web development. In the traditional way of doing web development, there are only HTML, CSS and JS. Do we need hydration to do dialogs in HTML, CSS and JS? No, but we have to right now, because hydration is a side effect of wanting to use a UI "library" to manage the DOM and its interactivity. There's nothing wrong with that if you need a UI "library" (not just for rendering the elements, but also organizing interactions and states), but UI "library" and a server, hence hydration, takes time to learn, which traditional websites and client-side apps doesn't have to.

With that said, looking at the Radix UI code, I think it is too complex (saying this from the perspective of a developer taking Radix UI's idea and build a composable unstyled UI component internally). Maybe it has to be that way to accommodate all of the components there are, but if you're using Radix UI and you hit a stone (just as I do when I have to write my own composable UI for a "Scrollable Horizontal List" and potentially wants to contribute to Radix UI), you're damned. Either spend 8 hours becoming a maintainer of Radix UI just to write your component that extends Radix UI's primitives, or you have to start from scratch.

Stones, like Radix UI's hydration error. Do you understand exactly why even with open={true}, the dialog still not open on the Server-side render pass? There's no way React do this by default - setting open={true} will just do nothing and decides:

"Hmm, maybe I should just use some effects to show something."

Is it React Portal's fault? Is it Radix UI's <Portal>'s fault? Is it the fact that the position of the dialog depends on the presence of the client-side because guessing the height of the user's viewport on the server is outright impossible? How do you go ahead and resolve the issue (and this GitHub issue altogether)?

So maybe the next time you got problems with Radix UI's SSR problem, maybe, just maybe, realize that Radix UI is working around React's problems just to be an unstyled UI library. Maybe you just have to understand everything and solve it yourself. I hope that you have the same vision as me, about the future where web development starts with HTML, CSS and JS (i.e. unstyled UI library/components for the web), not React (unstyled components in React).

Oudwins commented 1 year ago

@intagaming I agree with most of what you say. But the reality is that we need hydration because otherwise pages are super slow (mostly because, let's face it react is slow).

Also, I understand that dialogs and such depend on the size of the screen and therefore cannot hydrate. However, components such as dropdowns, if not open by default should be able to hydrate without issue.

intagaming commented 1 year ago

@intagaming I agree with most of what you say. But the reality is that we need hydration because otherwise pages are super slow (mostly because, let's face it react is slow).

Also, I understand that dialogs and such depend on the size of the screen and therefore cannot hydrate. However, components such as dropdowns, if not open by default should be able to hydrate without issue.

If anything in Radix UI is not open by default, then there is no HTML in the server-rendered thing, so it has nothing to hydrate. It should already work, isn't it? I'm using Astro SSR + React + Radix UI.

About hydration being faster: Your argument only holds if you are using a UI library/framework (like React). Takes Swiper.js. They are migrating from Swiper React and such to Swiper Element, a new variant that runs as Web Components. I think this is big step forward for libraries that want to achieve full compatibility with any web technology, not just React or Vue or such. I think there is a deciding point where you decide whether you should do your components in Web Components or stay in React land. Definitely not your average web application but for libraries? Being compatible with the web is a huge thing. jQuery can still be used in your React app because it is not a niche for the React ecosystem.

JacobGrady commented 1 year ago

This fixed it for me...

<DialogPrimitive.Portal container={document.body}>

iamshubhamjangle commented 1 year ago

container={document.body}

Thanks @JacobGrady . This worked! But why did it worked?

adomaitisc commented 1 year ago

This fixed it for me...

<DialogPrimitive.Portal container={document.body}>

That’s interesting, how did you come up with this workaround?

Oudwins commented 1 year ago

@intagaming Astro with client:load wasn't working for me. But I also had issues with react-country-flags. So I am not sure which package was at fault yet and haven't had time to work it out. I removed radix-ui and its a bit of a hassle to set it up again.

adkuca commented 1 year ago

This fixed it for me...

<DialogPrimitive.Portal container={document.body}>

@JacobGrady @adomaitisc @iamshubhamjangle look into your console, document doesn't exist on your server. It might cause hydration issues down the line.

Jervx commented 1 year ago

I fixed mine, I am passing a

adkuca commented 1 year ago

We're running into this as well. Building on top of @adomaitisc's solution, here's a component that only replaces the Root component (in this case for the Dialog, but could be AlertDialog just as well):

import type { DialogContentProps } from '@radix-ui/react-dialog'
import * as RadixDialog from '@radix-ui/react-dialog'

const Root: React.FC<RadixDialog.DialogProps> = (props) => {
  // This terrible hack is needed to prevent hydration errors.
  // The Radix Dialog is not rendered correctly server side, so we need to prevent it from rendering until the client side hydration is complete (and `useEffect` is run).
  // The issue is reported here: https://github.com/radix-ui/primitives/issues/1386
  const [open, setOpen] = useState<boolean | undefined>(props.open === undefined ? undefined : false)

  useEffect(() => {
    setOpen(props.open)
  }, [props.open, setOpen])

  return <RadixDialog.Root {...props} open={open} />
}

You can then use this custom Root instead of the default one.

If you want it open from the start then don't use Dialog.Portal, that simple. If you use ReactDOM.createPortal (Dialog.Portal) you have to have a reference element to attach the content to, and you don't have a DOM on the server, so you can't do that. Lets say you want to instantly show a dialog, you use Portal and have a form in there. Because server can't render it server side, you will be rendering null as your first render and then send all of portal content serialized js to the client where it will be rendered, where's the SEO? While if you just omit Portal and render the dialog with form content directly...

Am I missing something why you might want Portal?

leecobaby commented 1 year ago

I create prodiver to root layout fix this issuse

'use client'

import { useEffect, useState } from 'react'
import { Modal } from '@/components/modal'

export const ModalProvider = () => {
  const [isMounted, setIsMounted] = useState(false)

  useEffect(() => {
    setIsMounted(true)
  }, [])

  if (!isMounted) return null

  return (
    <>
      <Modal />
    </>
  )
}
med8bra commented 1 year ago

For me, this is a blocking issue, especially if I want Portal content to be crawlable for SEO purposes. Headless UI supports that.

I believe you should mention this limitation in your docs at least, so users don't waste time trying to make it work

Overview Server side rendering or SSR, is a technique used to render components to HTML on the server, as opposed to rendering them only on the client.
You should be able to use all of our primitives with both approaches, for example with Next.js or Gatsby.

All the above workarounds, are just to bypass rehydration issues, but still Portal isn't rendered on the server side

msalahz commented 1 year ago

I got the same issue and fixed it by creating the following component

'use client'

import * as React from 'react
import * as DialogPrimitive from '@radix-ui/react-dialog'

const Dialog = ({
  open,
  defaultOpen,
  ...props
}: DialogPrimitive.DialogProps) => {
  const [isOpen, setIsOpen] = React.useState<boolean>(false)

  React.useEffect(() => {
    setIsOpen(defaultOpen ?? open ?? false)
  }, [defaultOpen, open])

  return <DialogPrimitive.Root open={isOpen} {...props} />
}
mattbf commented 1 year ago

Simply using asChild on the Trigger component worked for me. Note my Dialog is not controlled. See this answer for details.

SSTPIERRE2 commented 1 year ago

As of today I still get Next.js hydration errors with the Portal primitive. The docs on SSR suggest they should work out of the box without any errors, but they don't.

FYI headlessui's portal works out of the box in NextJS SSR, curious how they did it, but the code is too complicated for me to understand https://github.com/tailwindlabs/headlessui/blob/main/packages/%40headlessui-react/src/components/portal/portal.tsx

Ah, I'll just use HeadlessUI's Portal, thanks!

michaelsoriano commented 1 year ago

Same issue using shadcn ui (AlertDialog). Fixed by adding:

image

Instead of hardcoding "true". Note that component is only shown / triggered by custom route

MiroslavPetrik commented 12 months ago

FYI headlessui's portal works out of the box in NextJS SSR, curious how they did it, but the code is too complicated for me to understand https://github.com/tailwindlabs/headlessui/blob/main/packages/%40headlessui-react/src/components/portal/portal.tsx

Headless UI supports the umount prop where you can control whether to always render, or always mount the modal when you show/hide it. As I understand it, for hydration to work the component must be rendered in server and client, so we don't want the mount/unmount behavior, instead we always want to render the structure.

WaveringAna commented 10 months ago

Simply using asChild on the Trigger component worked for me. Note my Dialog is not controlled. See this answer for details.

this is the easiest way

shawngustaw commented 9 months ago

I ran into this issue and there's not much you can do other than not use Portals, since they depend on the DOM. The easiest work around to get Dialogs working with SSR with default=open is to just render you dialog high enough in the component tree, which effectively removes the need for Portals entirely.

I abstracted this a bit in my component by allowing a Portal?: React.ElementType; prop and having it default to Portal = DialogPrimitive.Portal so if I want to opt out of portals I can just pass Portal={React.Fragment}. Hopefully this helps someone else!