primitives: [Portal] Hydration failure in SSR when initially rendered

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

About this issue

  • Original URL
  • State: open
  • Created 2 years ago
  • Reactions: 52
  • Comments: 36

Most upvoted comments

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.

Bruh why has this not been fixed 😦

This fixed it for me…

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

Bump +1ļøāƒ£

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).

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.

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

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

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

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.

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

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

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

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

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!

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

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} />
}

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?

@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.

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

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.