diff --git a/packages/@headlessui-react/src/components/portal/portal.test.tsx b/packages/@headlessui-react/src/components/portal/portal.test.tsx index 53bdd5c1f..41c874ed4 100644 --- a/packages/@headlessui-react/src/components/portal/portal.test.tsx +++ b/packages/@headlessui-react/src/components/portal/portal.test.tsx @@ -272,6 +272,42 @@ it('should be possible to tamper with the modal root and restore correctly', asy expect(getPortalRoot().childNodes).toHaveLength(2) }) +it('should restore the portal root to the end of the body after external reordering', async () => { + expect(getPortalRoot()).toBe(null) + + function Example() { + let [tick, setTick] = useState(0) + + return ( +
+ + + +

Contents...

+
+
+ ) + } + + let { container } = render() + + let rerender = document.getElementById('rerender') + + expect(document.body.lastElementChild).toBe(getPortalRoot()) + + document.body.insertBefore(getPortalRoot(), container) + + expect(document.body.firstElementChild).toBe(getPortalRoot()) + expect(document.body.lastElementChild).toBe(container) + + await click(rerender) + + expect(document.body.firstElementChild).toBe(container) + expect(document.body.lastElementChild).toBe(getPortalRoot()) +}) + it('should be possible to force the Portal into a specific element using Portal.Group', async () => { function Example() { let container = useRef(null) diff --git a/packages/@headlessui-react/src/components/portal/portal.tsx b/packages/@headlessui-react/src/components/portal/portal.tsx index 4bf04b0b8..afe40654e 100644 --- a/packages/@headlessui-react/src/components/portal/portal.tsx +++ b/packages/@headlessui-react/src/components/portal/portal.tsx @@ -16,6 +16,7 @@ import React, { import { createPortal } from 'react-dom' import { useDisposables } from '../../hooks/use-disposables' import { useEvent } from '../../hooks/use-event' +import { useIsoMorphicEffect } from '../../hooks/use-iso-morphic-effect' import { useOnUnmount } from '../../hooks/use-on-unmount' import { useOwnerDocument } from '../../hooks/use-owner' import { useServerHandoffComplete } from '../../hooks/use-server-handoff-complete' @@ -45,14 +46,20 @@ function usePortalTarget(ownerDocument: Document | null): HTMLElement | null { return ownerDocument.body.appendChild(root) }) - // Ensure the portal root is always in the DOM - useEffect(() => { + useIsoMorphicEffect(() => { if (target === null) return - - if (!ownerDocument?.body.contains(target)) { - ownerDocument?.body.appendChild(target) + if (ownerDocument === null) return + if (!forceInRoot && groupTarget !== null) return + + // Keep the shared portal root as the last element in the body so external + // DOM updates can't leave future dialogs behind newly rendered page content. + if ( + target.parentElement !== ownerDocument.body || + target !== ownerDocument.body.lastElementChild + ) { + ownerDocument.body.appendChild(target) } - }, [target, ownerDocument]) + }) useEffect(() => { if (forceInRoot) return