Skip to content

Commit dd22a53

Browse files
chore: Deprecate UNSTABLE_portalContainer in favor for PortalProvider (#7976)
* Initial refactor to tear out UNSTABLE_portalContainer in favor of the PortalContainer * yarn.lock update * switch to deprecating UNSTABLE_portalContainer * prefer deprecated prop over context to make this a non-breaking change * add rough docs * updating copy to include explaination of UNSTABLE * rename to UNSAFE_PortalProvider * update copy and split out example * use styles from RAC examples --------- Co-authored-by: Robert Snow <[email protected]>
1 parent 148fcf1 commit dd22a53

File tree

24 files changed

+351
-61
lines changed

24 files changed

+351
-61
lines changed
Lines changed: 138 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,138 @@
1+
{/* Copyright 2025 Adobe. All rights reserved.
2+
This file is licensed to you under the Apache License, Version 2.0 (the "License");
3+
you may not use this file except in compliance with the License. You may obtain a copy
4+
of the License at http://www.apache.org/licenses/LICENSE-2.0
5+
Unless required by applicable law or agreed to in writing, software distributed under
6+
the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS
7+
OF ANY KIND, either express or implied. See the License for the specific language
8+
governing permissions and limitations under the License. */}
9+
10+
import {Layout} from '@react-spectrum/docs';
11+
export default Layout;
12+
13+
import docs from 'docs:@react-aria/overlays';
14+
import {HeaderInfo, PropTable, FunctionAPI, PageDescription} from '@react-spectrum/docs';
15+
import packageData from '@react-aria/overlays/package.json';
16+
17+
---
18+
category: Utilities
19+
keywords: [overlays, portals]
20+
---
21+
22+
# PortalProvider
23+
24+
<PageDescription>{docs.exports.UNSAFE_PortalProvider.description}</PageDescription>
25+
26+
<HeaderInfo
27+
packageData={packageData}
28+
componentNames={['UNSAFE_PortalProvider', 'useUNSAFE_PortalContext']} />
29+
30+
## Introduction
31+
32+
`UNSAFE_PortalProvider` is a utility wrapper component that can be used to set where components like
33+
Modals, Popovers, Toasts, and Tooltips will portal their overlay element to. This is typically used when
34+
your app is already portalling other elements to a location other than the `document.body` and thus requires
35+
your React Aria components to send their overlays to the same container.
36+
37+
Please note that `UNSAFE_PortalProvider` is considered `UNSAFE` because it is an escape hatch, and there are
38+
many places that an application could portal to. Not all of them will work, either with styling, accessibility,
39+
or for a variety of other reasons. Typically, it is best to portal to the root of the entire application, e.g. the `body` element,
40+
outside of any possible overflow or stacking contexts. We envision `UNSAFE_PortalProvider` being used to group all of the portalled
41+
elements into a single container at the root of the app or to control the order of children of the `body` element, but you may have use cases
42+
that need to do otherwise.
43+
44+
## Props
45+
46+
<PropTable links={docs.links} component={docs.exports.UNSAFE_PortalProvider} />
47+
48+
## Example
49+
50+
The example below shows how you can use `UNSAFE_PortalProvider` to portal your Toasts to an arbitrary container. Note that
51+
the Toast in this example is taken directly from the [React Aria Components Toast documentation](Toast.html#example), please visit that page for
52+
a detailed explanation of its implementation.
53+
54+
```tsx import
55+
import {UNSTABLE_ToastRegion as ToastRegion, UNSTABLE_Toast as Toast, UNSTABLE_ToastQueue as ToastQueue, UNSTABLE_ToastContent as ToastContent, Button, Text} from 'react-aria-components';
56+
57+
58+
// Define the type for your toast content.
59+
interface MyToastContent {
60+
title: string,
61+
description?: string
62+
}
63+
64+
// Create a global ToastQueue.
65+
const queue = new ToastQueue<MyToastContent>();
66+
67+
function MyToastRegion() {
68+
return (
69+
<ToastRegion queue={queue}>
70+
{({toast}) => (
71+
<Toast toast={toast}>
72+
<ToastContent>
73+
<Text slot="title">{toast.content.title}</Text>
74+
<Text slot="description">{toast.content.description}</Text>
75+
</ToastContent>
76+
<Button slot="close">x</Button>
77+
</Toast>
78+
)}
79+
</ToastRegion>
80+
81+
);
82+
}
83+
```
84+
85+
```tsx example
86+
import {UNSAFE_PortalProvider} from '@react-aria/overlays';
87+
88+
// See the above Toast docs link for the ToastRegion implementation
89+
function App() {
90+
let container = React.useRef(null);
91+
return (
92+
<>
93+
<UNSAFE_PortalProvider getContainer={() => container.current}>
94+
<MyToastRegion />
95+
<Button
96+
onPress={() => queue.add({
97+
title: 'Toast complete!',
98+
description: 'Great success.'
99+
})}>
100+
Open Toast
101+
</Button>
102+
</UNSAFE_PortalProvider>
103+
<div ref={container} style={{height: '110px', width: '200px', overflow: 'auto', display: 'flex', flexDirection: 'column', gap: '20px', padding: '5px'}}>
104+
Toasts are portalled here!
105+
</div>
106+
</>
107+
);
108+
}
109+
110+
<App />
111+
```
112+
113+
```css hidden
114+
@import '../../../react-aria-components/docs/Button.mdx' layer(button);
115+
@import '../../../react-aria-components/docs/Toast.mdx' layer(toast);
116+
@import "@react-aria/example-theme";
117+
118+
.react-aria-ToastRegion {
119+
position: unset;
120+
}
121+
```
122+
123+
## Contexts
124+
125+
The `getContainer` set by the nearest PortalProvider can be accessed by calling `useUNSAFE_PortalContext`. This can be
126+
used by custom overlay components to ensure that they are also being consistently portalled throughout your app.
127+
128+
<FunctionAPI links={docs.links} function={docs.exports.useUNSAFE_PortalContext} />
129+
130+
```tsx
131+
import {useUNSAFE_PortalContext} from '@react-aria/overlays';
132+
133+
function MyOverlay(props) {
134+
let {children} = props;
135+
let {getContainer} = useUNSAFE_PortalContext();
136+
return ReactDOM.createPortal(children, getContainer());
137+
}
138+
```

packages/@react-aria/overlays/src/Overlay.tsx

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -16,12 +16,13 @@ import React, {ReactNode, useContext, useMemo, useState} from 'react';
1616
import ReactDOM from 'react-dom';
1717
import {useIsSSR} from '@react-aria/ssr';
1818
import {useLayoutEffect} from '@react-aria/utils';
19-
import {useUNSTABLE_PortalContext} from './PortalProvider';
19+
import {useUNSAFE_PortalContext} from './PortalProvider';
2020

2121
export interface OverlayProps {
2222
/**
2323
* The container element in which the overlay portal will be placed.
2424
* @default document.body
25+
* @deprecated - Use a parent UNSAFE_PortalProvider to set your portal container instead.
2526
*/
2627
portalContainer?: Element,
2728
/** The overlay to render in the portal. */
@@ -55,8 +56,8 @@ export function Overlay(props: OverlayProps): ReactNode | null {
5556
let [contain, setContain] = useState(false);
5657
let contextValue = useMemo(() => ({contain, setContain}), [contain, setContain]);
5758

58-
let {getContainer} = useUNSTABLE_PortalContext();
59-
if (!props.portalContainer && getContainer) {
59+
let {getContainer} = useUNSAFE_PortalContext();
60+
if (!props.portalContainer && getContainer) {
6061
portalContainer = getContainer();
6162
}
6263

packages/@react-aria/overlays/src/PortalProvider.tsx

Lines changed: 13 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -13,22 +13,29 @@
1313
import React, {createContext, ReactNode, useContext} from 'react';
1414

1515
export interface PortalProviderProps {
16-
/* Should return the element where we should portal to. Can clear the context by passing null. */
17-
getContainer?: () => HTMLElement | null
16+
/** Should return the element where we should portal to. Can clear the context by passing null. */
17+
getContainer?: () => HTMLElement | null,
18+
/** The content of the PortalProvider. Should contain all children that want to portal their overlays to the element returned by the provided `getContainer()`. */
19+
children: ReactNode
1820
}
1921

20-
export const PortalContext = createContext<PortalProviderProps>({});
22+
export interface PortalProviderContextValue extends Omit<PortalProviderProps, 'children'>{};
2123

22-
export function UNSTABLE_PortalProvider(props: PortalProviderProps & {children: ReactNode}): ReactNode {
24+
export const PortalContext = createContext<PortalProviderContextValue>({});
25+
26+
/**
27+
* Sets the portal container for all overlay elements rendered by its children.
28+
*/
29+
export function UNSAFE_PortalProvider(props: PortalProviderProps): ReactNode {
2330
let {getContainer} = props;
24-
let {getContainer: ctxGetContainer} = useUNSTABLE_PortalContext();
31+
let {getContainer: ctxGetContainer} = useUNSAFE_PortalContext();
2532
return (
2633
<PortalContext.Provider value={{getContainer: getContainer === null ? undefined : getContainer ?? ctxGetContainer}}>
2734
{props.children}
2835
</PortalContext.Provider>
2936
);
3037
}
3138

32-
export function useUNSTABLE_PortalContext(): PortalProviderProps {
39+
export function useUNSAFE_PortalContext(): PortalProviderContextValue {
3340
return useContext(PortalContext) ?? {};
3441
}

packages/@react-aria/overlays/src/index.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ export {ariaHideOutside} from './ariaHideOutside';
1919
export {usePopover} from './usePopover';
2020
export {useModalOverlay} from './useModalOverlay';
2121
export {Overlay, useOverlayFocusContain} from './Overlay';
22-
export {UNSTABLE_PortalProvider, useUNSTABLE_PortalContext} from './PortalProvider';
22+
export {UNSAFE_PortalProvider, useUNSAFE_PortalContext} from './PortalProvider';
2323

2424
export type {AriaPositionProps, PositionAria} from './useOverlayPosition';
2525
export type {AriaOverlayProps, OverlayAria} from './useOverlay';
@@ -30,3 +30,4 @@ export type {AriaPopoverProps, PopoverAria} from './usePopover';
3030
export type {AriaModalOverlayProps, ModalOverlayAria} from './useModalOverlay';
3131
export type {OverlayProps} from './Overlay';
3232
export type {Placement, PlacementAxis, PositionProps} from '@react-types/overlays';
33+
export type {PortalProviderProps, PortalProviderContextValue} from './PortalProvider';

packages/@react-aria/overlays/src/useModal.tsx

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import {DOMAttributes} from '@react-types/shared';
1414
import React, {AriaAttributes, ReactNode, useContext, useEffect, useMemo, useState} from 'react';
1515
import ReactDOM from 'react-dom';
1616
import {useIsSSR} from '@react-aria/ssr';
17+
import {useUNSAFE_PortalContext} from './PortalProvider';
1718

1819
export interface ModalProviderProps extends DOMAttributes {
1920
children: ReactNode
@@ -112,6 +113,7 @@ export interface OverlayContainerProps extends ModalProviderProps {
112113
/**
113114
* The container element in which the overlay portal will be placed.
114115
* @default document.body
116+
* @deprecated - Use a parent UNSAFE_PortalProvider to set your portal container instead.
115117
*/
116118
portalContainer?: Element
117119
}
@@ -126,6 +128,10 @@ export interface OverlayContainerProps extends ModalProviderProps {
126128
export function OverlayContainer(props: OverlayContainerProps): React.ReactPortal | null {
127129
let isSSR = useIsSSR();
128130
let {portalContainer = isSSR ? null : document.body, ...rest} = props;
131+
let {getContainer} = useUNSAFE_PortalContext();
132+
if (!props.portalContainer && getContainer) {
133+
portalContainer = getContainer();
134+
}
129135

130136
React.useEffect(() => {
131137
if (portalContainer?.closest('[data-overlay-container]')) {

packages/@react-spectrum/dialog/test/DialogContainer.test.js

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ import {Heading, Text} from '@react-spectrum/text';
2121
import {Provider} from '@react-spectrum/provider';
2222
import React, {useRef, useState} from 'react';
2323
import {theme} from '@react-spectrum/theme-default';
24-
import {UNSTABLE_PortalProvider} from '@react-aria/overlays';
24+
import {UNSAFE_PortalProvider} from '@react-aria/overlays';
2525
import userEvent from '@testing-library/user-event';
2626

2727
describe('DialogContainer', function () {
@@ -254,13 +254,13 @@ describe('DialogContainer', function () {
254254
return (
255255
<Provider theme={theme}>
256256
<ActionButton onPress={() => setOpen(true)}>Open dialog</ActionButton>
257-
<UNSTABLE_PortalProvider getContainer={() => container.current}>
257+
<UNSAFE_PortalProvider getContainer={() => container.current}>
258258
<DialogContainer onDismiss={() => setOpen(false)} {...props}>
259259
{isOpen &&
260260
<ExampleDialog {...props} />
261261
}
262262
</DialogContainer>
263-
</UNSTABLE_PortalProvider>
263+
</UNSAFE_PortalProvider>
264264
<div ref={container} data-testid="custom-container" />
265265
</Provider>
266266
);

packages/@react-spectrum/dialog/test/DialogTrigger.test.js

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ import {Provider} from '@react-spectrum/provider';
2121
import React from 'react';
2222
import {TextField} from '@react-spectrum/textfield';
2323
import {theme} from '@react-spectrum/theme-default';
24-
import {UNSTABLE_PortalProvider} from '@react-aria/overlays';
24+
import {UNSAFE_PortalProvider} from '@react-aria/overlays';
2525
import userEvent from '@testing-library/user-event';
2626

2727

@@ -1031,12 +1031,12 @@ describe('DialogTrigger', function () {
10311031
let {container} = props;
10321032
return (
10331033
<Provider theme={theme}>
1034-
<UNSTABLE_PortalProvider getContainer={() => container.current}>
1034+
<UNSAFE_PortalProvider getContainer={() => container.current}>
10351035
<DialogTrigger type={props.type}>
10361036
<ActionButton>Trigger</ActionButton>
10371037
<Dialog>contents</Dialog>
10381038
</DialogTrigger>
1039-
</UNSTABLE_PortalProvider>
1039+
</UNSAFE_PortalProvider>
10401040
</Provider>
10411041
);
10421042
}

packages/@react-spectrum/menu/test/MenuTrigger.test.js

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@ import {Link} from '@react-spectrum/link';
2929
import {Provider} from '@react-spectrum/provider';
3030
import React from 'react';
3131
import {theme} from '@react-spectrum/theme-default';
32-
import {UNSTABLE_PortalProvider} from '@react-aria/overlays';
32+
import {UNSAFE_PortalProvider} from '@react-aria/overlays';
3333
import {User} from '@react-aria/test-utils';
3434
import userEvent from '@testing-library/user-event';
3535

@@ -735,7 +735,7 @@ describe('MenuTrigger', function () {
735735
function InfoMenu(props) {
736736
return (
737737
<Provider theme={theme}>
738-
<UNSTABLE_PortalProvider getContainer={() => props.container.current}>
738+
<UNSAFE_PortalProvider getContainer={() => props.container.current}>
739739
<MenuTrigger>
740740
<ActionButton aria-label="trigger" />
741741
<Menu>
@@ -744,7 +744,7 @@ describe('MenuTrigger', function () {
744744
<Item key="3">Three</Item>
745745
</Menu>
746746
</MenuTrigger>
747-
</UNSTABLE_PortalProvider>
747+
</UNSAFE_PortalProvider>
748748
</Provider>
749749
);
750750
}

packages/@react-spectrum/table/src/Resizer.tsx

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
1+
22
import {classNames} from '@react-spectrum/utils';
33
import {ColumnSize} from '@react-types/table';
44
import eCursor from 'bundle-text:./cursors/Cur_MoveToRight_9_9.svg';
@@ -16,7 +16,7 @@ import {TableColumnResizeState} from '@react-stately/table';
1616
import {useLocale, useLocalizedStringFormatter} from '@react-aria/i18n';
1717
import {useTableColumnResize} from '@react-aria/table';
1818
import {useTableContext, useVirtualizerContext} from './TableViewBase';
19-
import {useUNSTABLE_PortalContext} from '@react-aria/overlays';
19+
import {useUNSAFE_PortalContext} from '@react-aria/overlays';
2020
// @ts-ignore
2121
import wCursor from 'bundle-text:./cursors/Cur_MoveToLeft_9_9.svg';
2222

@@ -132,6 +132,6 @@ export const Resizer = React.forwardRef(function Resizer<T>(props: ResizerProps<
132132

133133
function CursorOverlay(props) {
134134
let {show, children} = props;
135-
let {getContainer} = useUNSTABLE_PortalContext();
135+
let {getContainer} = useUNSAFE_PortalContext();
136136
return show ? ReactDOM.createPortal(children, getContainer?.() ?? document.body) : null;
137137
}

packages/@react-spectrum/table/test/TableSizing.test.tsx

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ import {resizingTests} from '@react-aria/table/test/tableResizingTests';
2626
import {Scale} from '@react-types/provider';
2727
import {setInteractionModality} from '@react-aria/interactions';
2828
import {theme} from '@react-spectrum/theme-default';
29-
import {UNSTABLE_PortalProvider} from '@react-aria/overlays';
29+
import {UNSAFE_PortalProvider} from '@react-aria/overlays';
3030
import userEvent from '@testing-library/user-event';
3131

3232
let columns = [
@@ -1048,7 +1048,7 @@ describe('TableViewSizing', function () {
10481048
let Example = (props) => {
10491049
let container = useRef(null);
10501050
return (
1051-
<UNSTABLE_PortalProvider getContainer={() => container.current}>
1051+
<UNSAFE_PortalProvider getContainer={() => container.current}>
10521052
<TableView aria-label="Table" onResizeEnd={props.onResizeEnd}>
10531053
<TableHeader>
10541054
<Column allowsResizing key="foo">Foo</Column>
@@ -1064,7 +1064,7 @@ describe('TableViewSizing', function () {
10641064
</TableBody>
10651065
</TableView>
10661066
<div id="custom-portal-container" ref={container} />
1067-
</UNSTABLE_PortalProvider>
1067+
</UNSAFE_PortalProvider>
10681068
);
10691069
};
10701070
let customPortalRender = (props) => render(<Example {...props} />);

0 commit comments

Comments
 (0)