Skip to content

Commit b0174e4

Browse files
committed
[portals] Ensure container is reactive from undefined
1 parent a6e1aec commit b0174e4

File tree

2 files changed

+83
-90
lines changed

2 files changed

+83
-90
lines changed

packages/react/src/floating-ui-react/components/FloatingPortal.test.tsx

Lines changed: 47 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,14 @@
1-
import { fireEvent, flushMicrotasks, render, screen } from '@mui/internal-test-utils';
21
import * as React from 'react';
3-
2+
import { fireEvent, flushMicrotasks, render, screen } from '@mui/internal-test-utils';
43
import { isJSDOM } from '@base-ui-components/utils/detectBrowser';
54
import { FloatingPortal, useFloating } from '../index';
5+
import type { UseFloatingPortalNodeProps } from './FloatingPortal';
6+
7+
interface AppProps {
8+
root?: UseFloatingPortalNodeProps['root'];
9+
}
610

7-
function App(props: {
8-
root?: HTMLElement | null | React.RefObject<HTMLElement | null>;
9-
id?: string;
10-
}) {
11+
function App(props: AppProps) {
1112
const [open, setOpen] = React.useState(false);
1213
const { refs } = useFloating({
1314
open,
@@ -25,30 +26,6 @@ function App(props: {
2526
}
2627

2728
describe.skipIf(!isJSDOM)('FloatingPortal', () => {
28-
test('creates a custom id node', async () => {
29-
render(<App id="custom-id" />);
30-
await flushMicrotasks();
31-
expect(document.querySelector('#custom-id')).toBeInTheDocument();
32-
});
33-
34-
test('uses a custom id node as the root', async () => {
35-
const customRoot = document.createElement('div');
36-
customRoot.id = 'custom-root';
37-
document.body.appendChild(customRoot);
38-
render(<App id="custom-root" />);
39-
fireEvent.click(screen.getByTestId('reference'));
40-
await flushMicrotasks();
41-
expect(screen.getByTestId('floating').parentElement?.parentElement).toBe(customRoot);
42-
customRoot.remove();
43-
});
44-
45-
test('creates a custom id node as the root', async () => {
46-
render(<App id="custom-id" />);
47-
fireEvent.click(screen.getByTestId('reference'));
48-
await flushMicrotasks();
49-
expect(screen.getByTestId('floating').parentElement?.parentElement?.id).toBe('custom-id');
50-
});
51-
5229
test('allows custom roots', async () => {
5330
const customRoot = document.createElement('div');
5431
customRoot.id = 'custom-root';
@@ -89,7 +66,7 @@ describe.skipIf(!isJSDOM)('FloatingPortal', () => {
8966
return (
9067
<React.Fragment>
9168
{renderRoot && <div ref={setRoot} data-testid="root" />}
92-
<App root={root} />;
69+
<App root={root} />
9370
</React.Fragment>
9471
);
9572
}
@@ -103,4 +80,43 @@ describe.skipIf(!isJSDOM)('FloatingPortal', () => {
10380
const root = screen.getByTestId('root');
10481
expect(root).toBe(subRoot?.parentElement);
10582
});
83+
84+
test('reattaches the portal when the root changes', async () => {
85+
const customRoot = document.createElement('div');
86+
document.body.appendChild(customRoot);
87+
88+
try {
89+
function RootSwitcher() {
90+
const [root, setRoot] = React.useState<UseFloatingPortalNodeProps['root']>(undefined);
91+
92+
return (
93+
<React.Fragment>
94+
<App root={root} />
95+
<button onClick={() => setRoot(undefined)} data-testid="use-undefined" />
96+
<button onClick={() => setRoot(customRoot)} data-testid="use-element" />
97+
</React.Fragment>
98+
);
99+
}
100+
101+
render(<RootSwitcher />);
102+
103+
fireEvent.click(screen.getByTestId('reference'));
104+
105+
expect((await screen.findByTestId('floating')).parentElement?.parentElement).toBe(
106+
document.body,
107+
);
108+
109+
fireEvent.click(screen.getByTestId('use-element'));
110+
111+
expect((await screen.findByTestId('floating')).parentElement?.parentElement).toBe(customRoot);
112+
113+
fireEvent.click(screen.getByTestId('use-undefined'));
114+
115+
const floatingInBodyAgain = await screen.findByTestId('floating');
116+
expect(floatingInBodyAgain.parentElement?.parentElement).toBe(document.body);
117+
expect(customRoot.contains(floatingInBodyAgain)).toBe(false);
118+
} finally {
119+
customRoot.remove();
120+
}
121+
});
106122
});

packages/react/src/floating-ui-react/components/FloatingPortal.tsx

Lines changed: 36 additions & 59 deletions
Original file line numberDiff line numberDiff line change
@@ -38,23 +38,24 @@ export const usePortalContext = () => React.useContext(PortalContext);
3838
const attr = createAttribute('portal');
3939

4040
export interface UseFloatingPortalNodeProps {
41-
id?: string;
4241
root?: HTMLElement | ShadowRoot | null | React.RefObject<HTMLElement | ShadowRoot | null>;
4342
}
4443

4544
/**
4645
* @see https://floating-ui.com/docs/FloatingPortal#usefloatingportalnode
4746
*/
4847
export function useFloatingPortalNode(props: UseFloatingPortalNodeProps = {}) {
49-
const { id, root } = props;
48+
const { root } = props;
5049

5150
const uniqueId = useId();
5251
const portalContext = usePortalContext();
5352

5453
const [portalNode, setPortalNode] = React.useState<HTMLElement | null>(null);
5554

5655
const portalNodeRef = React.useRef<HTMLDivElement | null>(null);
56+
const prevContainerRef = React.useRef<HTMLElement | ShadowRoot | null>(null);
5757

58+
// Cleanup when the portal node instance changes or unmounts.
5859
useIsoLayoutEffect(() => {
5960
return () => {
6061
portalNode?.remove();
@@ -63,81 +64,60 @@ export function useFloatingPortalNode(props: UseFloatingPortalNodeProps = {}) {
6364
// https://github.com/floating-ui/floating-ui/issues/2454
6465
queueMicrotask(() => {
6566
portalNodeRef.current = null;
67+
prevContainerRef.current = null;
6668
});
6769
};
6870
}, [portalNode]);
6971

72+
// Handle reactive `root` changes (including undefined <-> container changes)
7073
useIsoLayoutEffect(() => {
71-
// Wait for the uniqueId to be generated before creating the portal node in
72-
// React <18 (using `useFloatingId` instead of the native `useId`).
73-
// https://github.com/floating-ui/floating-ui/issues/2778
74-
if (!uniqueId) {
75-
return;
76-
}
77-
if (portalNodeRef.current) {
78-
return;
79-
}
80-
const existingIdRoot = id ? document.getElementById(id) : null;
81-
if (!existingIdRoot) {
82-
return;
83-
}
84-
85-
const subRoot = document.createElement('div');
86-
subRoot.id = uniqueId;
87-
subRoot.setAttribute(attr, '');
88-
existingIdRoot.appendChild(subRoot);
89-
portalNodeRef.current = subRoot;
90-
setPortalNode(subRoot);
91-
}, [id, uniqueId]);
92-
93-
useIsoLayoutEffect(() => {
94-
// Wait for the root to exist before creating the portal node. The root must
95-
// be stored in state, not a ref, for this to work reactively.
74+
// "Wait" mode: remove any existing node and pause until root changes.
9675
if (root === null) {
76+
if (portalNodeRef.current) {
77+
portalNodeRef.current.remove();
78+
portalNodeRef.current = null;
79+
setPortalNode(null);
80+
}
81+
prevContainerRef.current = null;
9782
return;
9883
}
84+
85+
// For React 17, as the id is generated in an effect instead of React.useId().
9986
if (!uniqueId) {
10087
return;
10188
}
102-
if (portalNodeRef.current) {
103-
return;
104-
}
10589

106-
let container = root || portalContext?.portalNode;
107-
if (container && !isNode(container)) {
108-
container = container.current;
109-
}
110-
container = container || document.body;
90+
const resolvedContainer =
91+
(root && (isNode(root) ? root : root.current)) || portalContext?.portalNode || document.body;
92+
93+
const containerChanged = resolvedContainer !== prevContainerRef.current;
11194

112-
let idWrapper: HTMLDivElement | null = null;
113-
if (id) {
114-
idWrapper = document.createElement('div');
115-
idWrapper.id = id;
116-
container.appendChild(idWrapper);
95+
if (portalNodeRef.current && containerChanged) {
96+
portalNodeRef.current.remove();
97+
portalNodeRef.current = null;
98+
setPortalNode(null);
11799
}
118100

119-
const subRoot = document.createElement('div');
101+
if (portalNodeRef.current) {
102+
return;
103+
}
120104

121-
subRoot.id = uniqueId;
122-
subRoot.setAttribute(attr, '');
105+
const portalElement = document.createElement('div');
106+
portalElement.id = uniqueId;
107+
portalElement.setAttribute(attr, '');
108+
resolvedContainer.appendChild(portalElement);
123109

124-
container = idWrapper || container;
125-
container.appendChild(subRoot);
110+
portalNodeRef.current = portalElement;
111+
prevContainerRef.current = resolvedContainer;
126112

127-
portalNodeRef.current = subRoot;
128-
setPortalNode(subRoot);
129-
}, [id, root, uniqueId, portalContext]);
113+
setPortalNode(portalElement);
114+
}, [root, uniqueId, portalContext]);
130115

131116
return portalNode;
132117
}
133118

134119
export interface FloatingPortalProps {
135120
children?: React.ReactNode;
136-
/**
137-
* Optionally selects the node with the id if it exists, or create it and
138-
* append it to the specified `root` (by default `document.body`).
139-
*/
140-
id?: string;
141121
/**
142122
* Specifies the root node the portal container will be appended to.
143123
*/
@@ -160,9 +140,9 @@ export interface FloatingPortalProps {
160140
* @internal
161141
*/
162142
export function FloatingPortal(props: FloatingPortalProps): React.JSX.Element {
163-
const { children, id, root, preserveTabOrder = true } = props;
143+
const { children, root, preserveTabOrder = true } = props;
164144

165-
const portalNode = useFloatingPortalNode({ id, root });
145+
const portalNode = useFloatingPortalNode({ root });
166146
const [focusManagerState, setFocusManagerState] = React.useState<FocusManagerState>(null);
167147

168148
const beforeOutsideRef = React.useRef<HTMLSpanElement>(null);
@@ -211,10 +191,7 @@ export function FloatingPortal(props: FloatingPortalProps): React.JSX.Element {
211191
}, [portalNode, preserveTabOrder, modal]);
212192

213193
React.useEffect(() => {
214-
if (!portalNode) {
215-
return;
216-
}
217-
if (open) {
194+
if (!portalNode || open) {
218195
return;
219196
}
220197
enableFocusInside(portalNode);

0 commit comments

Comments
 (0)