Use correct ownerDocument when using internal <Portal/> (#3594)

This PR improves the internal `<Portal>` component by allowing to pass
in a custom `ownerDocument`.

This fixes an issue if you do something like this:

```ts
import { Menu, MenuButton, MenuItem, MenuItems } from '@headlessui/react'
import { useState } from 'react'
import { createPortal } from 'react-dom'

export default function App() {
  let [target, setTarget] = useState(null)

  return (
    <div className="grid min-h-full place-content-center">
      <iframe
        ref={(iframe) => {
          if (!iframe) return
          if (target) return

          let el = iframe.contentDocument.createElement('div')
          iframe.contentDocument.body.appendChild(el)
          setTarget(el)
        }}
        className="h-[50px] w-[75px] border-black bg-white"
      >
        {target && createPortal(<MenuExample />, target)}
      </iframe>
    </div>
  )
}

function MenuExample() {
  return (
    <Menu>
      <MenuButton>Open</MenuButton>
      <MenuItems
        anchor="bottom"
        className="flex min-w-[var(--button-width)] flex-col bg-white shadow"
      >
        <MenuItem>
          <a className="block data-[focus]:bg-blue-100" href="/settings">
            Settings
          </a>
        </MenuItem>
        <MenuItem>
          <a className="block data-[focus]:bg-blue-100" href="/support">
            Support
          </a>
        </MenuItem>
        <MenuItem>
          <a className="block data-[focus]:bg-blue-100" href="/license">
            License
          </a>
        </MenuItem>
      </MenuItems>
    </Menu>
  )
}
```

---

Here is a little reproduction video. The `<Menu/>` you see is rendered
in an `<iframe>`, the goal is that `<MenuItems/>` _also_ render inside
of the `<iframe>`.

In the video below we start with the fix where you can see that the
items are inside the iframe (and unstyled because I didn't load any
styles). The second part of the video is the before, where you can see
that the `<MenuItems/>` escape the `<iframe>` and are styled. That's not
what we want.


https://github.com/user-attachments/assets/2da7627e-7846-4c4d-bb14-278f80a03cd8
This commit is contained in:
Robin Malfait
2024-12-12 16:45:02 +01:00
committed by GitHub
parent d71fb9cd2e
commit 03fe3c573d
7 changed files with 30 additions and 26 deletions
@@ -1685,6 +1685,7 @@ function OptionsFn<TTag extends ElementType = typeof DEFAULT_OPTIONS_TAG>(
actions.setOptionsElement,
setLocalOptionsElement
)
let portalOwnerDocument = useOwnerDocument(data.buttonElement || data.inputElement)
let ownerDocument = useOwnerDocument(data.optionsElement)
let usesOpenClosedState = useOpenClosed()
@@ -1819,7 +1820,7 @@ function OptionsFn<TTag extends ElementType = typeof DEFAULT_OPTIONS_TAG>(
let render = useRender()
return (
<Portal enabled={portal ? props.static || visible : false}>
<Portal enabled={portal ? props.static || visible : false} ownerDocument={portalOwnerDocument}>
<ComboboxDataContext.Provider
value={data.mode === ValueMode.Multi ? data : { ...data, isSelected }}
>
@@ -951,6 +951,7 @@ function OptionsFn<TTag extends ElementType = typeof DEFAULT_OPTIONS_TAG>(
let data = useData('Listbox.Options')
let actions = useActions('Listbox.Options')
let portalOwnerDocument = useOwnerDocument(data.buttonElement)
let ownerDocument = useOwnerDocument(data.optionsElement)
let usesOpenClosedState = useOpenClosed()
@@ -1163,7 +1164,7 @@ function OptionsFn<TTag extends ElementType = typeof DEFAULT_OPTIONS_TAG>(
let render = useRender()
return (
<Portal enabled={portal ? props.static || visible : false}>
<Portal enabled={portal ? props.static || visible : false} ownerDocument={portalOwnerDocument}>
<ListboxDataContext.Provider
value={data.mode === ValueMode.Multi ? data : { ...data, isSelected }}
>
@@ -638,6 +638,7 @@ function ItemsFn<TTag extends ElementType = typeof DEFAULT_ITEMS_TAG>(
useEvent((element) => dispatch({ type: ActionTypes.SetItemsElement, element })),
setLocalItemsElement
)
let portalOwnerDocument = useOwnerDocument(state.buttonElement)
let ownerDocument = useOwnerDocument(state.itemsElement)
// Always enable `portal` functionality, when `anchor` is enabled
@@ -824,7 +825,7 @@ function ItemsFn<TTag extends ElementType = typeof DEFAULT_ITEMS_TAG>(
let render = useRender()
return (
<Portal enabled={portal ? props.static || visible : false}>
<Portal enabled={portal ? props.static || visible : false} ownerDocument={portalOwnerDocument}>
{render({
ourProps,
theirProps,
@@ -888,6 +888,7 @@ function PanelFn<TTag extends ElementType = typeof DEFAULT_PANEL_TAG>(
useEvent((panel) => dispatch({ type: ActionTypes.SetPanel, panel })),
setLocalPanelElement
)
let portalOwnerDocument = useOwnerDocument(state.button)
let ownerDocument = useOwnerDocument(internalPanelRef)
useIsoMorphicEffect(() => {
@@ -1080,7 +1081,10 @@ function PanelFn<TTag extends ElementType = typeof DEFAULT_PANEL_TAG>(
<ResetOpenClosedProvider>
<PopoverPanelContext.Provider value={id}>
<PopoverAPIContext.Provider value={{ close, isPortalled }}>
<Portal enabled={portal ? props.static || visible : false}>
<Portal
enabled={portal ? props.static || visible : false}
ownerDocument={portalOwnerDocument}
>
{visible && isPortalled && (
<Hidden
id={beforePanelSentinelId}
@@ -25,12 +25,10 @@ import type { Props } from '../../types'
import { env } from '../../utils/env'
import { forwardRefWithAs, useRender, type HasDisplayName, type RefProp } from '../../utils/render'
function usePortalTarget(ref: MutableRefObject<HTMLElement | null>): HTMLElement | null {
function usePortalTarget(ownerDocument: Document | null): HTMLElement | null {
let forceInRoot = usePortalRoot()
let groupTarget = useContext(PortalGroupContext)
let ownerDocument = useOwnerDocument(ref)
let [target, setTarget] = useState(() => {
// Group context is used, but still null
if (!forceInRoot && groupTarget !== null) return groupTarget.current ?? null
@@ -77,13 +75,14 @@ export type PortalProps<TTag extends ElementType = typeof DEFAULT_PORTAL_TAG> =
PortalPropsWeControl,
{
enabled?: boolean
ownerDocument?: Document | null
}
>
let InternalPortalFn = forwardRefWithAs(function InternalPortalFn<
TTag extends ElementType = typeof DEFAULT_PORTAL_TAG,
>(props: PortalProps<TTag>, ref: Ref<HTMLElement>) {
let theirProps = props
let { ownerDocument: incomingOwnerDocument = null, ...theirProps } = props
let internalPortalRootRef = useRef<HTMLElement | null>(null)
let portalRef = useSyncRefs(
optionalRef<(typeof internalPortalRootRef)['current']>((ref) => {
@@ -91,8 +90,9 @@ let InternalPortalFn = forwardRefWithAs(function InternalPortalFn<
}),
ref
)
let ownerDocument = useOwnerDocument(internalPortalRootRef)
let target = usePortalTarget(internalPortalRootRef)
let defaultOwnerDocument = useOwnerDocument(internalPortalRootRef)
let ownerDocument = incomingOwnerDocument ?? defaultOwnerDocument
let target = usePortalTarget(ownerDocument)
let [element] = useState<HTMLDivElement | null>(() =>
env.isServer ? null : ownerDocument?.createElement('div') ?? null
)
@@ -154,12 +154,12 @@ function PortalFn<TTag extends ElementType = typeof DEFAULT_PORTAL_TAG>(
) {
let portalRef = useSyncRefs(ref)
let { enabled = true, ...theirProps } = props
let { enabled = true, ownerDocument, ...theirProps } = props
let render = useRender()
return enabled ? (
<InternalPortalFn {...theirProps} ref={portalRef} />
<InternalPortalFn {...theirProps} ownerDocument={ownerDocument} ref={portalRef} />
) : (
render({
ourProps: { ref: portalRef },
@@ -3,12 +3,11 @@ import { env } from './env'
export function getOwnerDocument<T extends Element | MutableRefObject<Element | null>>(
element: T | null | undefined
) {
): Document | null {
if (env.isServer) return null
if (element instanceof Node) return element.ownerDocument
if (element?.hasOwnProperty('current')) {
if (element.current instanceof Node) return element.current.ownerDocument
}
if (!element) return document
if ('ownerDocument' in element) return element.ownerDocument
if ('current' in element) return element.current?.ownerDocument ?? document
return document
return null
}
+6 -8
View File
@@ -2,15 +2,13 @@ import type { Ref } from 'vue'
import { dom } from './dom'
import { env } from './env'
export function getOwnerDocument<T extends HTMLElement | Ref<HTMLElement | null>>(
export function getOwnerDocument<T extends Element | Ref<Element | null>>(
element: T | null | undefined
) {
): Document | null {
if (env.isServer) return null
if (element instanceof Node) return element.ownerDocument
if (element?.hasOwnProperty('value')) {
let domElement = dom(element as any)
if (domElement) return domElement.ownerDocument
}
if (!element) return document
if ('ownerDocument' in element) return element.ownerDocument
if ('value' in element) return dom(element as any)?.ownerDocument ?? document
return document
return null
}