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:
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user