Skip to content

Commit fc312fe

Browse files
committed
refactor useRootContainers and introduce MainTreeProvider
As a general recap, when an outside click happens, we need to react to it and typically use the `useOutsideClick` hook. We also require the context of "allowed root containers", this means that clicking on a 3rd party toast when a dialog is open, that we allow this even though we are technically clicking outside of the dialog. This is simply because we don't have control over these elements. We also need a reference to know what the "main tree" container is, because this is the container where your application lives and we _know_ that we are not allowed to click on anything in this container. The complex part is getting a reference to this element. ```html <html> <head> <title></title> </head> <body> <div id="app"> <-- main root container --> <div></div> <div> <Popover></Popover> <!-- Current component --> </div> <div></div> </div> <!-- Allowed container #1 --> <3rd-party-toast-container></3rd-party-toast-container> </body> <!-- Allowed container #2 --> <grammarly-extension></grammarly-extension> </html> ``` Some examples: - In case of a `Dialog`, the `Dialog` is rendered in a `Portal` which means that a DOM ref to the `Dialog` or anything inside will not point to the "main tree" node. - In case of a `Popover` we can use the `PopoverButton` as an element that lives in the main tree. However, if you work with nested `Popover` components, and the outer `PopoverPanel` uses the `anchor` or `portal` props, then the inner `PortalButton` will not be in the main tree either because it will live in the portalled `PopoverPanel` of the parent. This is where the `MainTreeProvider` comes in handy. This component will use the passed in `node` as the main tree node reference and pass this via the context through the React tree. This means that a nested `Popover` will still use a reference from the parent `Popover`. In case of the `Dialog`, we wrap the `Dialog` itself with this provider which means that the provider will be in the main tree and can be used inside the portalled `Dialog`. Another part of the `MainTreeProvider` is that if no node exists in the parent (reading from context), and no node is provided via props, then we will briefly render a hidden element, find the root node of the main tree (aka, the parent element that is a direct child of the body, `body > *`). Once we found it, we remove the hidden element again. This way we don't keep unnecessary DOM nodes around.
1 parent 03bc0f9 commit fc312fe

File tree

3 files changed

+139
-71
lines changed

3 files changed

+139
-71
lines changed

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

+19-16
Original file line numberDiff line numberDiff line change
@@ -25,12 +25,15 @@ import { useIsTouchDevice } from '../../hooks/use-is-touch-device'
2525
import { useOnDisappear } from '../../hooks/use-on-disappear'
2626
import { useOutsideClick } from '../../hooks/use-outside-click'
2727
import { useOwnerDocument } from '../../hooks/use-owner'
28-
import { useRootContainers } from '../../hooks/use-root-containers'
28+
import {
29+
MainTreeProvider,
30+
useMainTreeNode,
31+
useRootContainers,
32+
} from '../../hooks/use-root-containers'
2933
import { useScrollLock } from '../../hooks/use-scroll-lock'
3034
import { useServerHandoffComplete } from '../../hooks/use-server-handoff-complete'
3135
import { useSyncRefs } from '../../hooks/use-sync-refs'
3236
import { CloseProvider } from '../../internal/close-provider'
33-
import { HoistFormFields } from '../../internal/form-fields'
3437
import { ResetOpenClosedProvider, State, useOpenClosed } from '../../internal/open-closed'
3538
import { ForcePortalRoot } from '../../internal/portal-force-root'
3639
import type { Props } from '../../types'
@@ -180,11 +183,9 @@ let InternalDialog = forwardRefWithAs(function InternalDialog<
180183
},
181184
}
182185

183-
let {
184-
resolveContainers: resolveRootContainers,
185-
mainTreeNodeRef,
186-
MainTreeNode,
187-
} = useRootContainers({
186+
let mainTreeNode = useMainTreeNode()
187+
let { resolveContainers: resolveRootContainers } = useRootContainers({
188+
mainTreeNode,
188189
portals,
189190
defaultContainers: [defaultContainer],
190191
})
@@ -207,8 +208,7 @@ let InternalDialog = forwardRefWithAs(function InternalDialog<
207208
]),
208209
disallowed: useEvent(() => [
209210
// Disallow the "main" tree root node
210-
mainTreeNodeRef.current?.closest<HTMLElement>('body > *:not(#headlessui-portal-root)') ??
211-
null,
211+
mainTreeNode?.closest<HTMLElement>('body > *:not(#headlessui-portal-root)') ?? null,
212212
]),
213213
})
214214

@@ -320,9 +320,6 @@ let InternalDialog = forwardRefWithAs(function InternalDialog<
320320
</DialogContext.Provider>
321321
</Portal>
322322
</ForcePortalRoot>
323-
<HoistFormFields>
324-
<MainTreeNode />
325-
</HoistFormFields>
326323
</ResetOpenClosedProvider>
327324
)
328325
})
@@ -395,13 +392,19 @@ function DialogFn<TTag extends ElementType = typeof DEFAULT_DIALOG_TAG>(
395392

396393
if ((open !== undefined || transition) && !rest.static) {
397394
return (
398-
<Transition show={open} transition={transition} unmount={rest.unmount}>
399-
<InternalDialog ref={ref} {...rest} />
400-
</Transition>
395+
<MainTreeProvider>
396+
<Transition show={open} transition={transition} unmount={rest.unmount}>
397+
<InternalDialog ref={ref} {...rest} />
398+
</Transition>
399+
</MainTreeProvider>
401400
)
402401
}
403402

404-
return <InternalDialog ref={ref} open={open} {...rest} />
403+
return (
404+
<MainTreeProvider>
405+
<InternalDialog ref={ref} open={open} {...rest} />
406+
</MainTreeProvider>
407+
)
405408
}
406409

407410
// ---

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

+28-30
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,11 @@ import { useOnDisappear } from '../../hooks/use-on-disappear'
3232
import { useOutsideClick } from '../../hooks/use-outside-click'
3333
import { useOwnerDocument } from '../../hooks/use-owner'
3434
import { useResolveButtonType } from '../../hooks/use-resolve-button-type'
35-
import { useMainTreeNode, useRootContainers } from '../../hooks/use-root-containers'
35+
import {
36+
MainTreeProvider,
37+
useMainTreeNode,
38+
useRootContainers,
39+
} from '../../hooks/use-root-containers'
3640
import { useScrollLock } from '../../hooks/use-scroll-lock'
3741
import { optionalRef, useSyncRefs } from '../../hooks/use-sync-refs'
3842
import { Direction as TabDirection, useTabDirection } from '../../hooks/use-tab-direction'
@@ -195,7 +199,6 @@ let PopoverGroupContext = createContext<{
195199
unregisterPopover(registerBag: PopoverRegisterBag): void
196200
isFocusWithinPopoverGroup(): boolean
197201
closeOthers(buttonId: string): void
198-
mainTreeNodeRef: MutableRefObject<HTMLElement | null>
199202
} | null>(null)
200203
PopoverGroupContext.displayName = 'PopoverGroupContext'
201204

@@ -343,8 +346,9 @@ function PopoverFn<TTag extends ElementType = typeof DEFAULT_POPOVER_TAG>(
343346
useEffect(() => registerPopover?.(registerBag), [registerPopover, registerBag])
344347

345348
let [portals, PortalWrapper] = useNestedPortals()
349+
let mainTreeNode = useMainTreeNode(button)
346350
let root = useRootContainers({
347-
mainTreeNodeRef: groupContext?.mainTreeNodeRef,
351+
mainTreeNode,
348352
portals,
349353
defaultContainers: [button, panel],
350354
})
@@ -428,14 +432,15 @@ function PopoverFn<TTag extends ElementType = typeof DEFAULT_POPOVER_TAG>(
428432
})}
429433
>
430434
<PortalWrapper>
431-
{render({
432-
ourProps,
433-
theirProps,
434-
slot,
435-
defaultTag: DEFAULT_POPOVER_TAG,
436-
name: 'Popover',
437-
})}
438-
<root.MainTreeNode />
435+
<MainTreeProvider node={mainTreeNode}>
436+
{render({
437+
ourProps,
438+
theirProps,
439+
slot,
440+
defaultTag: DEFAULT_POPOVER_TAG,
441+
name: 'Popover',
442+
})}
443+
</MainTreeProvider>
439444
</PortalWrapper>
440445
</OpenClosedProvider>
441446
</CloseProvider>
@@ -1110,7 +1115,6 @@ function GroupFn<TTag extends ElementType = typeof DEFAULT_GROUP_TAG>(
11101115
let internalGroupRef = useRef<HTMLElement | null>(null)
11111116
let groupRef = useSyncRefs(internalGroupRef, ref)
11121117
let [popovers, setPopovers] = useState<PopoverRegisterBag[]>([])
1113-
let root = useMainTreeNode()
11141118

11151119
let unregisterPopover = useEvent((registerBag: PopoverRegisterBag) => {
11161120
setPopovers((existing) => {
@@ -1157,15 +1161,8 @@ function GroupFn<TTag extends ElementType = typeof DEFAULT_GROUP_TAG>(
11571161
unregisterPopover: unregisterPopover,
11581162
isFocusWithinPopoverGroup,
11591163
closeOthers,
1160-
mainTreeNodeRef: root.mainTreeNodeRef,
11611164
}),
1162-
[
1163-
registerPopover,
1164-
unregisterPopover,
1165-
isFocusWithinPopoverGroup,
1166-
closeOthers,
1167-
root.mainTreeNodeRef,
1168-
]
1165+
[registerPopover, unregisterPopover, isFocusWithinPopoverGroup, closeOthers]
11691166
)
11701167

11711168
let slot = useMemo(() => ({}) satisfies GroupRenderPropArg, [])
@@ -1174,16 +1171,17 @@ function GroupFn<TTag extends ElementType = typeof DEFAULT_GROUP_TAG>(
11741171
let ourProps = { ref: groupRef }
11751172

11761173
return (
1177-
<PopoverGroupContext.Provider value={contextBag}>
1178-
{render({
1179-
ourProps,
1180-
theirProps,
1181-
slot,
1182-
defaultTag: DEFAULT_GROUP_TAG,
1183-
name: 'Popover.Group',
1184-
})}
1185-
<root.MainTreeNode />
1186-
</PopoverGroupContext.Provider>
1174+
<MainTreeProvider>
1175+
<PopoverGroupContext.Provider value={contextBag}>
1176+
{render({
1177+
ourProps,
1178+
theirProps,
1179+
slot,
1180+
defaultTag: DEFAULT_GROUP_TAG,
1181+
name: 'Popover.Group',
1182+
})}
1183+
</PopoverGroupContext.Provider>
1184+
</MainTreeProvider>
11871185
)
11881186
}
11891187

packages/@headlessui-react/src/hooks/use-root-containers.tsx

+92-25
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,21 @@
1-
import React, { useMemo, useRef, type MutableRefObject } from 'react'
1+
import React, { createContext, useContext, useState, type MutableRefObject } from 'react'
22
import { Hidden, HiddenFeatures } from '../internal/hidden'
3+
import { getOwnerDocument } from '../utils/owner'
34
import { useEvent } from './use-event'
45
import { useOwnerDocument } from './use-owner'
56

67
export function useRootContainers({
78
defaultContainers = [],
89
portals,
9-
mainTreeNodeRef: _mainTreeNodeRef,
10+
11+
// Reference to a node in the "main" tree, not in the portalled Dialog tree.
12+
mainTreeNode,
1013
}: {
1114
defaultContainers?: (HTMLElement | null | MutableRefObject<HTMLElement | null>)[]
1215
portals?: MutableRefObject<HTMLElement[]>
13-
mainTreeNodeRef?: MutableRefObject<HTMLElement | null>
16+
mainTreeNode?: HTMLElement | null
1417
} = {}) {
15-
// Reference to a node in the "main" tree, not in the portalled Dialog tree.
16-
let mainTreeNodeRef = useRef<HTMLElement | null>(_mainTreeNodeRef?.current ?? null)
17-
let ownerDocument = useOwnerDocument(mainTreeNodeRef)
18+
let ownerDocument = useOwnerDocument(mainTreeNode)
1819

1920
let resolveContainers = useEvent(() => {
2021
let containers: HTMLElement[] = []
@@ -42,8 +43,10 @@ export function useRootContainers({
4243
if (container === document.head) continue // Skip `<head>`
4344
if (!(container instanceof HTMLElement)) continue // Skip non-HTMLElements
4445
if (container.id === 'headlessui-portal-root') continue // Skip the Headless UI portal root
45-
if (container.contains(mainTreeNodeRef.current)) continue // Skip if it is the main app
46-
if (container.contains((mainTreeNodeRef.current?.getRootNode() as ShadowRoot)?.host)) continue // Skip if it is the main app (and the component is inside a shadow root)
46+
if (mainTreeNode) {
47+
if (container.contains(mainTreeNode)) continue // Skip if it is the main app
48+
if (container.contains((mainTreeNode?.getRootNode() as ShadowRoot)?.host)) continue // Skip if it is the main app (and the component is inside a shadow root)
49+
}
4750
if (containers.some((defaultContainer) => container.contains(defaultContainer))) continue // Skip if the current container is part of a container we've already seen (e.g.: default container / portal)
4851

4952
containers.push(container)
@@ -57,25 +60,89 @@ export function useRootContainers({
5760
contains: useEvent((element: HTMLElement) =>
5861
resolveContainers().some((container) => container.contains(element))
5962
),
60-
mainTreeNodeRef,
61-
MainTreeNode: useMemo(() => {
62-
return function MainTreeNode() {
63-
if (_mainTreeNodeRef != null) return null
64-
return <Hidden features={HiddenFeatures.Hidden} ref={mainTreeNodeRef} />
65-
}
66-
}, [mainTreeNodeRef, _mainTreeNodeRef]),
6763
}
6864
}
6965

70-
export function useMainTreeNode() {
71-
let mainTreeNodeRef = useRef<HTMLElement | null>(null)
66+
let MainTreeContext = createContext<HTMLElement | null>(null)
7267

73-
return {
74-
mainTreeNodeRef,
75-
MainTreeNode: useMemo(() => {
76-
return function MainTreeNode() {
77-
return <Hidden features={HiddenFeatures.Hidden} ref={mainTreeNodeRef} />
78-
}
79-
}, [mainTreeNodeRef]),
80-
}
68+
/**
69+
* A provider for the main tree node.
70+
*
71+
* When a component is rendered in a `Portal`, it is no longer part of the main
72+
* tree. This provider helps to find the main tree node and pass it along to the
73+
* components that need it.
74+
*
75+
* The main tree node is used for features such as outside click behavior, where
76+
* we allow clicks in 3rd party contains, but not in the parent of the "main
77+
* tree".
78+
*
79+
* In case of a `Popover`, we can use the `PopoverButton` as a marker in the
80+
* "main tree", the `PopoverPanel` can't be used because it could be rendered in
81+
* a `Portal` (e.g.: when using the `anchor` props).
82+
*
83+
* However, we can't use the `PopoverButton` when it's nested inside of another
84+
* `Popover`'s `PopoverPanel` component (if that parent `PopoverPanel` is
85+
* rendered in a `Portal`).
86+
*
87+
* This is where the `MainTreeProvider` comes in. It will find the "main tree"
88+
* node and pass it on. The top-level `PopoverButton` will be used as a marker
89+
* in the "main tree" and nested `Popover` use this button as well.
90+
*/
91+
export function MainTreeProvider({
92+
children,
93+
node,
94+
}: {
95+
children: React.ReactNode
96+
node?: HTMLElement | null
97+
}) {
98+
let [mainTreeNode, setMainTreeNode] = useState<HTMLElement | null>(null)
99+
100+
// 1. Prefer the main tree node from context
101+
// 2. Prefer the provided node
102+
// 3. Create a new node at this point, and find the main tree node
103+
let resolvedMainTreeNode = useMainTreeNode(node ?? mainTreeNode)
104+
105+
return (
106+
<MainTreeContext.Provider value={resolvedMainTreeNode}>
107+
{children}
108+
109+
{/**
110+
* If no main tree node is found at this point, then we briefly render an
111+
* element to find the main tree node and pass it along.
112+
*/}
113+
{resolvedMainTreeNode === null && (
114+
<Hidden
115+
features={HiddenFeatures.Hidden}
116+
ref={(el) => {
117+
if (el) {
118+
// We will only render this when no `mainTreeNode` is found. This
119+
// means that if we render this element and use it as the
120+
// `mainTreeNode` that we will be unmounting it later again.
121+
//
122+
// However, we can resolve the actual root container of the main
123+
// tree node and use that instead.
124+
for (let container of getOwnerDocument(el)?.querySelectorAll('html > *, body > *') ??
125+
[]) {
126+
if (container === document.body) continue // Skip `<body>`
127+
if (container === document.head) continue // Skip `<head>`
128+
if (!(container instanceof HTMLElement)) continue // Skip non-HTMLElements
129+
if (container?.contains(el)) {
130+
setMainTreeNode(container)
131+
break
132+
}
133+
}
134+
}
135+
}}
136+
/>
137+
)}
138+
</MainTreeContext.Provider>
139+
)
140+
}
141+
142+
/**
143+
* Get the main tree node from context or fallback to the optionally provided node.
144+
*/
145+
export function useMainTreeNode(fallbackMainTreeNode: HTMLElement | null = null) {
146+
// Prefer the main tree node from context, but fallback to the provided node.
147+
return useContext(MainTreeContext) ?? fallbackMainTreeNode
81148
}

0 commit comments

Comments
 (0)