Skip to content

Commit 030773c

Browse files
authored
Ensure ref is always connected when rendering in Portal (#3789)
This PR fixes an issue where elements of a `<Portal />` were not connected on subsequent renders. Our Portal component is relatively complex because we want to make sure we use the correct `document`, only keep a single portal root (and clean it up when there are no more portals) and each portal gets its own `<div data-headlessui-portal>`. This is also where the bug was, because we were creating the `data-headlessui-portal` div manually. Cleaning all that up, and let React take care of most of that fixes the issue as well. ## Test plan 1. All existing tests pass 2. Elements are now connected on subsequent renders, verified using the reproduction steps in #3681 Before: https://github.com/user-attachments/assets/28dde5de-505e-4f2c-bf34-2feb4fe8fac7 After: https://github.com/user-attachments/assets/d54cfa6a-8d86-47e7-9c5b-dbc620a86ef8 Fixes: #3681
1 parent 9dc83e0 commit 030773c

File tree

2 files changed

+26
-42
lines changed

2 files changed

+26
-42
lines changed

packages/@headlessui-react/CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1616
- Ensure pressing `Tab` in the `ComboboxInput`, correctly syncs the input value ([#3785](https://github.com/tailwindlabs/headlessui/pull/3785))
1717
- Ensure `--button-width` and `--input-width` have the latest value ([#3786](https://github.com/tailwindlabs/headlessui/pull/3786))
1818
- Fix 'Invalid prop `data-headlessui-state` supplied to `React.Fragment`' warning ([#3788](https://github.com/tailwindlabs/headlessui/pull/3788))
19+
- Ensure `element` in `ref` callback is always connected when rendering in a `Portal` ([#3789](https://github.com/tailwindlabs/headlessui/pull/3789))
1920

2021
## [2.2.7] - 2025-07-30
2122

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

Lines changed: 25 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -14,15 +14,13 @@ import React, {
1414
type Ref,
1515
} from 'react'
1616
import { createPortal } from 'react-dom'
17+
import { useDisposables } from '../../hooks/use-disposables'
1718
import { useEvent } from '../../hooks/use-event'
18-
import { useIsoMorphicEffect } from '../../hooks/use-iso-morphic-effect'
1919
import { useOnUnmount } from '../../hooks/use-on-unmount'
2020
import { useOwnerDocument } from '../../hooks/use-owner'
21-
import { useServerHandoffComplete } from '../../hooks/use-server-handoff-complete'
2221
import { optionalRef, useSyncRefs } from '../../hooks/use-sync-refs'
2322
import { usePortalRoot } from '../../internal/portal-force-root'
2423
import type { Props } from '../../types'
25-
import * as DOM from '../../utils/dom'
2624
import { env } from '../../utils/env'
2725
import { forwardRefWithAs, useRender, type HasDisplayName, type RefProp } from '../../utils/render'
2826

@@ -94,58 +92,43 @@ let InternalPortalFn = forwardRefWithAs(function InternalPortalFn<
9492
let defaultOwnerDocument = useOwnerDocument(internalPortalRootRef)
9593
let ownerDocument = incomingOwnerDocument ?? defaultOwnerDocument
9694
let target = usePortalTarget(ownerDocument)
97-
let [element] = useState<HTMLDivElement | null>(() =>
98-
env.isServer ? null : ownerDocument?.createElement('div') ?? null
99-
)
10095
let parent = useContext(PortalParentContext)
101-
let ready = useServerHandoffComplete()
102-
103-
useIsoMorphicEffect(() => {
104-
if (!target || !element) return
105-
106-
// Element already exists in target, always calling target.appendChild(element) will cause a
107-
// brief unmount/remount.
108-
if (!target.contains(element)) {
109-
element.setAttribute('data-headlessui-portal', '')
110-
target.appendChild(element)
111-
}
112-
}, [target, element])
113-
114-
useIsoMorphicEffect(() => {
115-
if (!element) return
116-
if (!parent) return
117-
118-
return parent.register(element)
119-
}, [parent, element])
96+
let d = useDisposables()
97+
let render = useRender()
12098

12199
useOnUnmount(() => {
122-
if (!target || !element) return
123-
124-
if (DOM.isNode(element) && target.contains(element)) {
125-
target.removeChild(element)
126-
}
100+
if (!target) return
127101

102+
// Cleanup the portal root when all portals are unmounted
128103
if (target.childNodes.length <= 0) {
129104
target.parentElement?.removeChild(target)
130105
}
131106
})
132107

133-
let render = useRender()
134-
if (!ready) return null
135-
136108
let ourProps = { ref: portalRef }
137109

138-
return !target || !element
110+
return !target
139111
? null
140112
: createPortal(
141-
render({
142-
ourProps,
143-
theirProps,
144-
slot: {},
145-
defaultTag: DEFAULT_PORTAL_TAG,
146-
name: 'Portal',
147-
}),
148-
element
113+
<div
114+
data-headlessui-portal=""
115+
ref={(el) => {
116+
d.dispose()
117+
118+
if (parent && el) {
119+
d.add(parent.register(el))
120+
}
121+
}}
122+
>
123+
{render({
124+
ourProps,
125+
theirProps,
126+
slot: {},
127+
defaultTag: DEFAULT_PORTAL_TAG,
128+
name: 'Portal',
129+
})}
130+
</div>,
131+
target
149132
)
150133
})
151134

0 commit comments

Comments
 (0)