Skip to content

Commit 03fe3c5

Browse files
authored
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
1 parent d71fb9c commit 03fe3c5

File tree

7 files changed

+30
-26
lines changed

7 files changed

+30
-26
lines changed

packages/@headlessui-react/src/components/combobox/combobox.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1685,6 +1685,7 @@ function OptionsFn<TTag extends ElementType = typeof DEFAULT_OPTIONS_TAG>(
16851685
actions.setOptionsElement,
16861686
setLocalOptionsElement
16871687
)
1688+
let portalOwnerDocument = useOwnerDocument(data.buttonElement || data.inputElement)
16881689
let ownerDocument = useOwnerDocument(data.optionsElement)
16891690

16901691
let usesOpenClosedState = useOpenClosed()
@@ -1819,7 +1820,7 @@ function OptionsFn<TTag extends ElementType = typeof DEFAULT_OPTIONS_TAG>(
18191820
let render = useRender()
18201821

18211822
return (
1822-
<Portal enabled={portal ? props.static || visible : false}>
1823+
<Portal enabled={portal ? props.static || visible : false} ownerDocument={portalOwnerDocument}>
18231824
<ComboboxDataContext.Provider
18241825
value={data.mode === ValueMode.Multi ? data : { ...data, isSelected }}
18251826
>

packages/@headlessui-react/src/components/listbox/listbox.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -951,6 +951,7 @@ function OptionsFn<TTag extends ElementType = typeof DEFAULT_OPTIONS_TAG>(
951951
let data = useData('Listbox.Options')
952952
let actions = useActions('Listbox.Options')
953953

954+
let portalOwnerDocument = useOwnerDocument(data.buttonElement)
954955
let ownerDocument = useOwnerDocument(data.optionsElement)
955956

956957
let usesOpenClosedState = useOpenClosed()
@@ -1163,7 +1164,7 @@ function OptionsFn<TTag extends ElementType = typeof DEFAULT_OPTIONS_TAG>(
11631164
let render = useRender()
11641165

11651166
return (
1166-
<Portal enabled={portal ? props.static || visible : false}>
1167+
<Portal enabled={portal ? props.static || visible : false} ownerDocument={portalOwnerDocument}>
11671168
<ListboxDataContext.Provider
11681169
value={data.mode === ValueMode.Multi ? data : { ...data, isSelected }}
11691170
>

packages/@headlessui-react/src/components/menu/menu.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -638,6 +638,7 @@ function ItemsFn<TTag extends ElementType = typeof DEFAULT_ITEMS_TAG>(
638638
useEvent((element) => dispatch({ type: ActionTypes.SetItemsElement, element })),
639639
setLocalItemsElement
640640
)
641+
let portalOwnerDocument = useOwnerDocument(state.buttonElement)
641642
let ownerDocument = useOwnerDocument(state.itemsElement)
642643

643644
// Always enable `portal` functionality, when `anchor` is enabled
@@ -824,7 +825,7 @@ function ItemsFn<TTag extends ElementType = typeof DEFAULT_ITEMS_TAG>(
824825
let render = useRender()
825826

826827
return (
827-
<Portal enabled={portal ? props.static || visible : false}>
828+
<Portal enabled={portal ? props.static || visible : false} ownerDocument={portalOwnerDocument}>
828829
{render({
829830
ourProps,
830831
theirProps,

packages/@headlessui-react/src/components/popover/popover.tsx

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -888,6 +888,7 @@ function PanelFn<TTag extends ElementType = typeof DEFAULT_PANEL_TAG>(
888888
useEvent((panel) => dispatch({ type: ActionTypes.SetPanel, panel })),
889889
setLocalPanelElement
890890
)
891+
let portalOwnerDocument = useOwnerDocument(state.button)
891892
let ownerDocument = useOwnerDocument(internalPanelRef)
892893

893894
useIsoMorphicEffect(() => {
@@ -1080,7 +1081,10 @@ function PanelFn<TTag extends ElementType = typeof DEFAULT_PANEL_TAG>(
10801081
<ResetOpenClosedProvider>
10811082
<PopoverPanelContext.Provider value={id}>
10821083
<PopoverAPIContext.Provider value={{ close, isPortalled }}>
1083-
<Portal enabled={portal ? props.static || visible : false}>
1084+
<Portal
1085+
enabled={portal ? props.static || visible : false}
1086+
ownerDocument={portalOwnerDocument}
1087+
>
10841088
{visible && isPortalled && (
10851089
<Hidden
10861090
id={beforePanelSentinelId}

packages/@headlessui-react/src/components/portal/portal.tsx

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -25,12 +25,10 @@ import type { Props } from '../../types'
2525
import { env } from '../../utils/env'
2626
import { forwardRefWithAs, useRender, type HasDisplayName, type RefProp } from '../../utils/render'
2727

28-
function usePortalTarget(ref: MutableRefObject<HTMLElement | null>): HTMLElement | null {
28+
function usePortalTarget(ownerDocument: Document | null): HTMLElement | null {
2929
let forceInRoot = usePortalRoot()
3030
let groupTarget = useContext(PortalGroupContext)
3131

32-
let ownerDocument = useOwnerDocument(ref)
33-
3432
let [target, setTarget] = useState(() => {
3533
// Group context is used, but still null
3634
if (!forceInRoot && groupTarget !== null) return groupTarget.current ?? null
@@ -77,22 +75,24 @@ export type PortalProps<TTag extends ElementType = typeof DEFAULT_PORTAL_TAG> =
7775
PortalPropsWeControl,
7876
{
7977
enabled?: boolean
78+
ownerDocument?: Document | null
8079
}
8180
>
8281

8382
let InternalPortalFn = forwardRefWithAs(function InternalPortalFn<
8483
TTag extends ElementType = typeof DEFAULT_PORTAL_TAG,
8584
>(props: PortalProps<TTag>, ref: Ref<HTMLElement>) {
86-
let theirProps = props
85+
let { ownerDocument: incomingOwnerDocument = null, ...theirProps } = props
8786
let internalPortalRootRef = useRef<HTMLElement | null>(null)
8887
let portalRef = useSyncRefs(
8988
optionalRef<(typeof internalPortalRootRef)['current']>((ref) => {
9089
internalPortalRootRef.current = ref
9190
}),
9291
ref
9392
)
94-
let ownerDocument = useOwnerDocument(internalPortalRootRef)
95-
let target = usePortalTarget(internalPortalRootRef)
93+
let defaultOwnerDocument = useOwnerDocument(internalPortalRootRef)
94+
let ownerDocument = incomingOwnerDocument ?? defaultOwnerDocument
95+
let target = usePortalTarget(ownerDocument)
9696
let [element] = useState<HTMLDivElement | null>(() =>
9797
env.isServer ? null : ownerDocument?.createElement('div') ?? null
9898
)
@@ -154,12 +154,12 @@ function PortalFn<TTag extends ElementType = typeof DEFAULT_PORTAL_TAG>(
154154
) {
155155
let portalRef = useSyncRefs(ref)
156156

157-
let { enabled = true, ...theirProps } = props
157+
let { enabled = true, ownerDocument, ...theirProps } = props
158158

159159
let render = useRender()
160160

161161
return enabled ? (
162-
<InternalPortalFn {...theirProps} ref={portalRef} />
162+
<InternalPortalFn {...theirProps} ownerDocument={ownerDocument} ref={portalRef} />
163163
) : (
164164
render({
165165
ourProps: { ref: portalRef },

packages/@headlessui-react/src/utils/owner.ts

Lines changed: 5 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -3,12 +3,11 @@ import { env } from './env'
33

44
export function getOwnerDocument<T extends Element | MutableRefObject<Element | null>>(
55
element: T | null | undefined
6-
) {
6+
): Document | null {
77
if (env.isServer) return null
8-
if (element instanceof Node) return element.ownerDocument
9-
if (element?.hasOwnProperty('current')) {
10-
if (element.current instanceof Node) return element.current.ownerDocument
11-
}
8+
if (!element) return document
9+
if ('ownerDocument' in element) return element.ownerDocument
10+
if ('current' in element) return element.current?.ownerDocument ?? document
1211

13-
return document
12+
return null
1413
}

packages/@headlessui-vue/src/utils/owner.ts

Lines changed: 6 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -2,15 +2,13 @@ import type { Ref } from 'vue'
22
import { dom } from './dom'
33
import { env } from './env'
44

5-
export function getOwnerDocument<T extends HTMLElement | Ref<HTMLElement | null>>(
5+
export function getOwnerDocument<T extends Element | Ref<Element | null>>(
66
element: T | null | undefined
7-
) {
7+
): Document | null {
88
if (env.isServer) return null
9-
if (element instanceof Node) return element.ownerDocument
10-
if (element?.hasOwnProperty('value')) {
11-
let domElement = dom(element as any)
12-
if (domElement) return domElement.ownerDocument
13-
}
9+
if (!element) return document
10+
if ('ownerDocument' in element) return element.ownerDocument
11+
if ('value' in element) return dom(element as any)?.ownerDocument ?? document
1412

15-
return document
13+
return null
1614
}

0 commit comments

Comments
 (0)