Skip to content

chore: Deprecate UNSTABLE_portalContainer in favor for PortalProvider #7976

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 10 commits into from
Apr 7, 2025
138 changes: 138 additions & 0 deletions packages/@react-aria/overlays/docs/PortalProvider.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
{/* Copyright 2025 Adobe. All rights reserved.
This file is licensed to you under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License. You may obtain a copy
of the License at http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software distributed under
the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS
OF ANY KIND, either express or implied. See the License for the specific language
governing permissions and limitations under the License. */}

import {Layout} from '@react-spectrum/docs';
export default Layout;

import docs from 'docs:@react-aria/overlays';
import {HeaderInfo, PropTable, FunctionAPI, PageDescription} from '@react-spectrum/docs';
import packageData from '@react-aria/overlays/package.json';

---
category: Utilities
keywords: [overlays, portals]
---

# PortalProvider

<PageDescription>{docs.exports.UNSAFE_PortalProvider.description}</PageDescription>

<HeaderInfo
packageData={packageData}
componentNames={['UNSAFE_PortalProvider', 'useUNSAFE_PortalContext']} />

## Introduction

`UNSAFE_PortalProvider` is a utility wrapper component that can be used to set where components like
Modals, Popovers, Toasts, and Tooltips will portal their overlay element to. This is typically used when
your app is already portalling other elements to a location other than the `document.body` and thus requires
your React Aria components to send their overlays to the same container.

Please note that `UNSAFE_PortalProvider` is considered `UNSAFE` because it is an escape hatch, and there are
many places that an application could portal to. Not all of them will work, either with styling, accessibility,
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,
outside of any possible overflow or stacking contexts. We envision `UNSAFE_PortalProvider` being used to group all of the portalled
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
that need to do otherwise.

## Props

<PropTable links={docs.links} component={docs.exports.UNSAFE_PortalProvider} />

## Example
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bit of a contrived simple example, open to suggestions on how complex I should make it.

Also I wasn't able to get the styles from the RAC Toast pages to work here via the @import './Button.mdx' layer(button); syntax, not quite sure why :/, still digging there.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This should work:

@import '../../../react-aria-components/docs/Button.mdx' layer(button);
@import "@react-aria/example-theme";

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ah derp, thanks. I didn't realize that the util pages were in a different path of the site


The example below shows how you can use `UNSAFE_PortalProvider` to portal your Toasts to an arbitrary container. Note that
the Toast in this example is taken directly from the [React Aria Components Toast documentation](Toast.html#example), please visit that page for
a detailed explanation of its implementation.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

add something like

"This is a contrived example, we do not recommend portalling toasts to a container in the middle of an application."

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we really want/need to point that out? IMHO, I think it should be fairly clear that this is a contrived example for the sake of illustrating the API, I figure people who come to this page will already have a defined use case in mind for the PortalProvider.

Happy to add it if others also feel like it should be added, just trying to be as conservative as possible with the docs length as we've discussed in the past

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That's fine
Would it be a shorter example to use Tooltips? Or is there a bunch of style overrides you'd need to do for it?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I mainly opted not to use tooltips because it felt like a less likely use case (at least it felt like the PortalProvider came up more in a Toast context both from external and internal users haha)


```tsx import
import {UNSTABLE_ToastRegion as ToastRegion, UNSTABLE_Toast as Toast, UNSTABLE_ToastQueue as ToastQueue, UNSTABLE_ToastContent as ToastContent, Button, Text} from 'react-aria-components';


// Define the type for your toast content.
interface MyToastContent {
title: string,
description?: string
}

// Create a global ToastQueue.
const queue = new ToastQueue<MyToastContent>();

function MyToastRegion() {
return (
<ToastRegion queue={queue}>
{({toast}) => (
<Toast toast={toast}>
<ToastContent>
<Text slot="title">{toast.content.title}</Text>
<Text slot="description">{toast.content.description}</Text>
</ToastContent>
<Button slot="close">x</Button>
</Toast>
)}
</ToastRegion>

);
}
```

```tsx example
import {UNSAFE_PortalProvider} from '@react-aria/overlays';

// See the above Toast docs link for the ToastRegion implementation
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This comment didn't appear. Maybe split this into two blocks: tsx import to just define the components without rendering them.

function App() {
let container = React.useRef(null);
return (
<>
<UNSAFE_PortalProvider getContainer={() => container.current}>
<MyToastRegion />
<Button
onPress={() => queue.add({
title: 'Toast complete!',
description: 'Great success.'
})}>
Open Toast
</Button>
</UNSAFE_PortalProvider>
<div ref={container} style={{height: '110px', width: '200px', overflow: 'auto', display: 'flex', flexDirection: 'column', gap: '20px', padding: '5px'}}>
Toasts are portalled here!
</div>
</>
);
}

<App />
```

```css hidden
@import '../../../react-aria-components/docs/Button.mdx' layer(button);
@import '../../../react-aria-components/docs/Toast.mdx' layer(toast);
@import "@react-aria/example-theme";

.react-aria-ToastRegion {
position: unset;
}
```

## Contexts

The `getContainer` set by the nearest PortalProvider can be accessed by calling `useUNSAFE_PortalContext`. This can be
used by custom overlay components to ensure that they are also being consistently portalled throughout your app.

<FunctionAPI links={docs.links} function={docs.exports.useUNSAFE_PortalContext} />

```tsx
import {useUNSAFE_PortalContext} from '@react-aria/overlays';

function MyOverlay(props) {
let {children} = props;
let {getContainer} = useUNSAFE_PortalContext();
return ReactDOM.createPortal(children, getContainer());
}
```
7 changes: 4 additions & 3 deletions packages/@react-aria/overlays/src/Overlay.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,12 +16,13 @@ import React, {ReactNode, useContext, useMemo, useState} from 'react';
import ReactDOM from 'react-dom';
import {useIsSSR} from '@react-aria/ssr';
import {useLayoutEffect} from '@react-aria/utils';
import {useUNSTABLE_PortalContext} from './PortalProvider';
import {useUNSAFE_PortalContext} from './PortalProvider';

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

let {getContainer} = useUNSTABLE_PortalContext();
if (!props.portalContainer && getContainer) {
let {getContainer} = useUNSAFE_PortalContext();
if (!props.portalContainer && getContainer) {
portalContainer = getContainer();
}

Expand Down
19 changes: 13 additions & 6 deletions packages/@react-aria/overlays/src/PortalProvider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,22 +13,29 @@
import React, {createContext, ReactNode, useContext} from 'react';

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

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

export function UNSTABLE_PortalProvider(props: PortalProviderProps & {children: ReactNode}): ReactNode {
export const PortalContext = createContext<PortalProviderContextValue>({});

/**
* Sets the portal container for all overlay elements rendered by its children.
*/
export function UNSAFE_PortalProvider(props: PortalProviderProps): ReactNode {
let {getContainer} = props;
let {getContainer: ctxGetContainer} = useUNSTABLE_PortalContext();
let {getContainer: ctxGetContainer} = useUNSAFE_PortalContext();
return (
<PortalContext.Provider value={{getContainer: getContainer === null ? undefined : getContainer ?? ctxGetContainer}}>
{props.children}
</PortalContext.Provider>
);
}

export function useUNSTABLE_PortalContext(): PortalProviderProps {
export function useUNSAFE_PortalContext(): PortalProviderContextValue {
return useContext(PortalContext) ?? {};
}
3 changes: 2 additions & 1 deletion packages/@react-aria/overlays/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ export {ariaHideOutside} from './ariaHideOutside';
export {usePopover} from './usePopover';
export {useModalOverlay} from './useModalOverlay';
export {Overlay, useOverlayFocusContain} from './Overlay';
export {UNSTABLE_PortalProvider, useUNSTABLE_PortalContext} from './PortalProvider';
export {UNSAFE_PortalProvider, useUNSAFE_PortalContext} from './PortalProvider';

export type {AriaPositionProps, PositionAria} from './useOverlayPosition';
export type {AriaOverlayProps, OverlayAria} from './useOverlay';
Expand All @@ -30,3 +30,4 @@ export type {AriaPopoverProps, PopoverAria} from './usePopover';
export type {AriaModalOverlayProps, ModalOverlayAria} from './useModalOverlay';
export type {OverlayProps} from './Overlay';
export type {Placement, PlacementAxis, PositionProps} from '@react-types/overlays';
export type {PortalProviderProps, PortalProviderContextValue} from './PortalProvider';
6 changes: 6 additions & 0 deletions packages/@react-aria/overlays/src/useModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import {DOMAttributes} from '@react-types/shared';
import React, {AriaAttributes, ReactNode, useContext, useEffect, useMemo, useState} from 'react';
import ReactDOM from 'react-dom';
import {useIsSSR} from '@react-aria/ssr';
import {useUNSAFE_PortalContext} from './PortalProvider';

export interface ModalProviderProps extends DOMAttributes {
children: ReactNode
Expand Down Expand Up @@ -112,6 +113,7 @@ export interface OverlayContainerProps extends ModalProviderProps {
/**
* The container element in which the overlay portal will be placed.
* @default document.body
* @deprecated - Use a parent UNSAFE_PortalProvider to set your portal container instead.
*/
portalContainer?: Element
}
Expand All @@ -126,6 +128,10 @@ export interface OverlayContainerProps extends ModalProviderProps {
export function OverlayContainer(props: OverlayContainerProps): React.ReactPortal | null {
let isSSR = useIsSSR();
let {portalContainer = isSSR ? null : document.body, ...rest} = props;
let {getContainer} = useUNSAFE_PortalContext();
if (!props.portalContainer && getContainer) {
portalContainer = getContainer();
}

React.useEffect(() => {
if (portalContainer?.closest('[data-overlay-container]')) {
Expand Down
6 changes: 3 additions & 3 deletions packages/@react-spectrum/dialog/test/DialogContainer.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ import {Heading, Text} from '@react-spectrum/text';
import {Provider} from '@react-spectrum/provider';
import React, {useRef, useState} from 'react';
import {theme} from '@react-spectrum/theme-default';
import {UNSTABLE_PortalProvider} from '@react-aria/overlays';
import {UNSAFE_PortalProvider} from '@react-aria/overlays';
import userEvent from '@testing-library/user-event';

describe('DialogContainer', function () {
Expand Down Expand Up @@ -254,13 +254,13 @@ describe('DialogContainer', function () {
return (
<Provider theme={theme}>
<ActionButton onPress={() => setOpen(true)}>Open dialog</ActionButton>
<UNSTABLE_PortalProvider getContainer={() => container.current}>
<UNSAFE_PortalProvider getContainer={() => container.current}>
<DialogContainer onDismiss={() => setOpen(false)} {...props}>
{isOpen &&
<ExampleDialog {...props} />
}
</DialogContainer>
</UNSTABLE_PortalProvider>
</UNSAFE_PortalProvider>
<div ref={container} data-testid="custom-container" />
</Provider>
);
Expand Down
6 changes: 3 additions & 3 deletions packages/@react-spectrum/dialog/test/DialogTrigger.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ import {Provider} from '@react-spectrum/provider';
import React from 'react';
import {TextField} from '@react-spectrum/textfield';
import {theme} from '@react-spectrum/theme-default';
import {UNSTABLE_PortalProvider} from '@react-aria/overlays';
import {UNSAFE_PortalProvider} from '@react-aria/overlays';
import userEvent from '@testing-library/user-event';


Expand Down Expand Up @@ -1031,12 +1031,12 @@ describe('DialogTrigger', function () {
let {container} = props;
return (
<Provider theme={theme}>
<UNSTABLE_PortalProvider getContainer={() => container.current}>
<UNSAFE_PortalProvider getContainer={() => container.current}>
<DialogTrigger type={props.type}>
<ActionButton>Trigger</ActionButton>
<Dialog>contents</Dialog>
</DialogTrigger>
</UNSTABLE_PortalProvider>
</UNSAFE_PortalProvider>
</Provider>
);
}
Expand Down
6 changes: 3 additions & 3 deletions packages/@react-spectrum/menu/test/MenuTrigger.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ import {Link} from '@react-spectrum/link';
import {Provider} from '@react-spectrum/provider';
import React from 'react';
import {theme} from '@react-spectrum/theme-default';
import {UNSTABLE_PortalProvider} from '@react-aria/overlays';
import {UNSAFE_PortalProvider} from '@react-aria/overlays';
import {User} from '@react-aria/test-utils';
import userEvent from '@testing-library/user-event';

Expand Down Expand Up @@ -735,7 +735,7 @@ describe('MenuTrigger', function () {
function InfoMenu(props) {
return (
<Provider theme={theme}>
<UNSTABLE_PortalProvider getContainer={() => props.container.current}>
<UNSAFE_PortalProvider getContainer={() => props.container.current}>
<MenuTrigger>
<ActionButton aria-label="trigger" />
<Menu>
Expand All @@ -744,7 +744,7 @@ describe('MenuTrigger', function () {
<Item key="3">Three</Item>
</Menu>
</MenuTrigger>
</UNSTABLE_PortalProvider>
</UNSAFE_PortalProvider>
</Provider>
);
}
Expand Down
6 changes: 3 additions & 3 deletions packages/@react-spectrum/table/src/Resizer.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@

import {classNames} from '@react-spectrum/utils';
import {ColumnSize} from '@react-types/table';
import eCursor from 'bundle-text:./cursors/Cur_MoveToRight_9_9.svg';
Expand All @@ -16,7 +16,7 @@ import {TableColumnResizeState} from '@react-stately/table';
import {useLocale, useLocalizedStringFormatter} from '@react-aria/i18n';
import {useTableColumnResize} from '@react-aria/table';
import {useTableContext, useVirtualizerContext} from './TableViewBase';
import {useUNSTABLE_PortalContext} from '@react-aria/overlays';
import {useUNSAFE_PortalContext} from '@react-aria/overlays';
// @ts-ignore
import wCursor from 'bundle-text:./cursors/Cur_MoveToLeft_9_9.svg';

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

function CursorOverlay(props) {
let {show, children} = props;
let {getContainer} = useUNSTABLE_PortalContext();
let {getContainer} = useUNSAFE_PortalContext();
return show ? ReactDOM.createPortal(children, getContainer?.() ?? document.body) : null;
}
6 changes: 3 additions & 3 deletions packages/@react-spectrum/table/test/TableSizing.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ import {resizingTests} from '@react-aria/table/test/tableResizingTests';
import {Scale} from '@react-types/provider';
import {setInteractionModality} from '@react-aria/interactions';
import {theme} from '@react-spectrum/theme-default';
import {UNSTABLE_PortalProvider} from '@react-aria/overlays';
import {UNSAFE_PortalProvider} from '@react-aria/overlays';
import userEvent from '@testing-library/user-event';

let columns = [
Expand Down Expand Up @@ -1048,7 +1048,7 @@ describe('TableViewSizing', function () {
let Example = (props) => {
let container = useRef(null);
return (
<UNSTABLE_PortalProvider getContainer={() => container.current}>
<UNSAFE_PortalProvider getContainer={() => container.current}>
<TableView aria-label="Table" onResizeEnd={props.onResizeEnd}>
<TableHeader>
<Column allowsResizing key="foo">Foo</Column>
Expand All @@ -1064,7 +1064,7 @@ describe('TableViewSizing', function () {
</TableBody>
</TableView>
<div id="custom-portal-container" ref={container} />
</UNSTABLE_PortalProvider>
</UNSAFE_PortalProvider>
);
};
let customPortalRender = (props) => render(<Example {...props} />);
Expand Down
4 changes: 2 additions & 2 deletions packages/@react-spectrum/toast/src/Toaster.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ import ReactDOM from 'react-dom';
import toastContainerStyles from './toastContainer.css';
import type {ToastPlacement} from './ToastContainer';
import {ToastState} from '@react-stately/toast';
import {useUNSTABLE_PortalContext} from '@react-aria/overlays';
import {useUNSAFE_PortalContext} from '@react-aria/overlays';

interface ToastContainerProps extends AriaToastRegionProps {
children: ReactNode,
Expand All @@ -39,7 +39,7 @@ export function Toaster(props: ToastContainerProps): ReactElement {
let ref = useRef(null);
let {regionProps} = useToastRegion(props, state, ref);
let {focusProps, isFocusVisible} = useFocusRing();
let {getContainer} = useUNSTABLE_PortalContext();
let {getContainer} = useUNSAFE_PortalContext();

let [position, placement] = useMemo(() => {
let [pos = 'bottom', place = 'center'] = props.placement?.split(' ') || [];
Expand Down
Loading