Closed andy-hook closed 5 months ago
It seems the original portal had some code to bail out of ssr. Do we need similar for the new one?
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.
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.
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"
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.
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>
);
}
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
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
Bruh why has this not been fixed :(
Bump +1️⃣
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>
);
}
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.
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
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.
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 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;
};
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).
@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 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.
This fixed it for me...
<DialogPrimitive.Portal container={document.body}>
container={document.body}
Thanks @JacobGrady . This worked! But why did it worked?
This fixed it for me...
<DialogPrimitive.Portal container={document.body}>
That’s interesting, how did you come up with this workaround?
@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.
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 fixed mine, I am passing a
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 beAlertDialog
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?
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 />
</>
)
}
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 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} />
}
Simply using asChild
on the Trigger component worked for me. Note my Dialog is not controlled. See this answer for details.
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!
Same issue using shadcn ui (AlertDialog). Fixed by adding:
Instead of hardcoding "true". Note that component is only shown / triggered by custom route
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.
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 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!
This manifests itself in
Dialog
andAlertDialog
when usingPortal
withDefaultOpen
in SSR env.https://codesandbox.io/s/friendly-morning-3ny0r5?file=/pages/index.tsx