Skip to content

Commit

Permalink
Use correct ownerDocument when using internal <Portal/> (#3594)
Browse files Browse the repository at this point in the history
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
  • Loading branch information
RobinMalfait authored Dec 12, 2024
1 parent d71fb9c commit 03fe3c5
Show file tree
Hide file tree
Showing 7 changed files with 30 additions and 26 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -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 }}
>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -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 }}
>
Expand Down
3 changes: 2 additions & 1 deletion packages/@headlessui-react/src/components/menu/menu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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(() => {
Expand Down Expand Up @@ -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}
Expand Down
16 changes: 8 additions & 8 deletions packages/@headlessui-react/src/components/portal/portal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -77,22 +75,24 @@ 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) => {
internalPortalRootRef.current = ref
}),
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
)
Expand Down Expand Up @@ -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 },
Expand Down
11 changes: 5 additions & 6 deletions packages/@headlessui-react/src/utils/owner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
14 changes: 6 additions & 8 deletions packages/@headlessui-vue/src/utils/owner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
}

0 comments on commit 03fe3c5

Please sign in to comment.