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
Merged
3 changes: 2 additions & 1 deletion packages/@react-aria/overlays/src/Overlay.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ export interface OverlayProps {
/**
* The container element in which the overlay portal will be placed.
* @default document.body
* @deprecated - Use a parent UNSTABLE_PortalProvider to set your portal container instead.
*/
portalContainer?: Element,
/** The overlay to render in the portal. */
Expand Down Expand Up @@ -56,7 +57,7 @@ export function Overlay(props: OverlayProps): ReactNode | null {
let contextValue = useMemo(() => ({contain, setContain}), [contain, setContain]);

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

Expand Down
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 {useUNSTABLE_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 UNSTABLE_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} = useUNSTABLE_PortalContext();
if (!props.portalContainer && getContainer) {
portalContainer = getContainer();
}

React.useEffect(() => {
if (portalContainer?.closest('[data-overlay-container]')) {
Expand Down
1 change: 1 addition & 0 deletions packages/react-aria-components/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@
"@react-aria/focus": "^3.20.1",
"@react-aria/interactions": "^3.24.1",
"@react-aria/live-announcer": "^3.4.1",
"@react-aria/overlays": "^3.26.1",
"@react-aria/ssr": "^3.9.7",
"@react-aria/toolbar": "3.0.0-beta.14",
"@react-aria/utils": "^3.28.1",
Expand Down
1 change: 1 addition & 0 deletions packages/react-aria-components/src/Modal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ export interface ModalOverlayProps extends AriaModalOverlayProps, OverlayTrigger
/**
* The container element in which the overlay portal will be placed. This may have unknown behavior depending on where it is portalled to.
* @default document.body
* @deprecated - Use a parent UNSTABLE_PortalProvider to set your portal container instead.
*/
UNSTABLE_portalContainer?: Element
}
Expand Down
3 changes: 2 additions & 1 deletion packages/react-aria-components/src/Popover.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ export interface PopoverProps extends Omit<PositionProps, 'isOpen'>, Omit<AriaPo
/**
* The container element in which the overlay portal will be placed. This may have unknown behavior depending on where it is portalled to.
* @default document.body
* @deprecated - Use a parent UNSTABLE_PortalProvider to set your portal container instead.
*/
UNSTABLE_portalContainer?: Element,
/**
Expand Down Expand Up @@ -154,7 +155,7 @@ function PopoverInner({state, isExiting, UNSTABLE_portalContainer, ...props}: Po
...props,
offset: props.offset ?? 8,
arrowSize: arrowWidth,
// If this is a submenu/subdialog, use the root popover's container
// If this is a submenu/subdialog, use the root popover's container
// to detect outside interaction and add aria-hidden.
groupRef: isSubPopover ? groupCtx! : containerRef
}, state);
Expand Down
17 changes: 10 additions & 7 deletions packages/react-aria-components/src/Toast.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import React, {createContext, ForwardedRef, forwardRef, HTMLAttributes, JSX, Rea
import {TextContext} from './Text';
import {useIsSSR} from '@react-aria/ssr';
import {useObjectRef} from '@react-aria/utils';
import {useUNSTABLE_PortalContext} from '@react-aria/overlays';

const ToastStateContext = createContext<ToastState<any> | null>(null);

Expand All @@ -42,12 +43,7 @@ export interface ToastRegionProps<T> extends AriaToastRegionProps, StyleRenderPr
/** The queue of toasts to display. */
queue: ToastQueue<T>,
/** A function to render each toast. */
children: (renderProps: {toast: QueuedToast<T>}) => ReactElement,
/**
* The container element in which the toast region portal will be placed.
* @default document.body
*/
portalContainer?: Element
children: (renderProps: {toast: QueuedToast<T>}) => ReactElement
}

/**
Expand All @@ -71,7 +67,14 @@ export const ToastRegion = /*#__PURE__*/ (forwardRef as forwardRefType)(function
}
});

let {portalContainer = isSSR ? null : document.body} = props;
let portalContainer;
let {getContainer} = useUNSTABLE_PortalContext();
if (!isSSR) {
portalContainer = document.body;
if (getContainer) {
portalContainer = getContainer();
}
}

let region = (
<ToastStateContext.Provider value={state}>
Expand Down
1 change: 1 addition & 0 deletions packages/react-aria-components/src/Tooltip.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ export interface TooltipProps extends PositionProps, Pick<AriaPositionProps, 'ar
/**
* The container element in which the overlay portal will be placed. This may have unknown behavior depending on where it is portalled to.
* @default document.body
* @deprecated - Use a parent UNSTABLE_PortalProvider to set your portal container instead.
*/
UNSTABLE_portalContainer?: Element,
/**
Expand Down
45 changes: 43 additions & 2 deletions packages/react-aria-components/test/Dialog.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,8 @@ import {
Popover
} from '../';
import {pointerMap, render, within} from '@react-spectrum/test-utils-internal';
import React from 'react';
import React, {useRef} from 'react';
import {UNSTABLE_PortalProvider} from '@react-aria/overlays';
import userEvent from '@testing-library/user-event';

describe('Dialog', () => {
Expand Down Expand Up @@ -302,6 +303,46 @@ describe('Dialog', () => {
expect(modal).not.toBeInTheDocument();
});

describe('portalProvider', () => {
function InfoDialog() {
return (
<DialogTrigger>
<Button>Delete…</Button>
<Modal data-test="modal">
<Dialog role="alertdialog" data-test="dialog">
{({close}) => (
<>
<Heading slot="title">Alert</Heading>
<Button onPress={close}>Close</Button>
</>
)}
</Dialog>
</Modal>
</DialogTrigger>
);
}
function App() {
let container = useRef(null);
return (
<>
<UNSTABLE_PortalProvider getContainer={() => container.current}>
<InfoDialog container={container} />
</UNSTABLE_PortalProvider>
<div ref={container} data-testid="custom-container" />
</>
);
}
it('should render the dialog in the portal container provided by the PortalProvider', async () => {
let {getByRole, getByTestId} = render(<App />);
let button = getByRole('button');
await user.click(button);

expect(getByRole('alertdialog').closest('[data-testid="custom-container"]')).toBe(getByTestId('custom-container'));
await user.click(document.body);
});
});

// TODO: delete this test when we get rid of the deprecated prop
describe('portalContainer', () => {
function InfoDialog(props) {
return (
Expand Down Expand Up @@ -329,7 +370,7 @@ describe('Dialog', () => {
</>
);
}
it('should render the tooltip in the portal container', async () => {
it('should render the dialog in the portal container', async () => {
let {getByRole, getByTestId} = render(<App />);
let button = getByRole('button');
await user.click(button);
Expand Down
43 changes: 42 additions & 1 deletion packages/react-aria-components/test/Popover.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,8 @@

import {act, pointerMap, render} from '@react-spectrum/test-utils-internal';
import {Button, Dialog, DialogTrigger, OverlayArrow, Popover, Pressable} from '../';
import React from 'react';
import React, {useRef} from 'react';
import {UNSTABLE_PortalProvider} from '@react-aria/overlays';
import userEvent from '@testing-library/user-event';

let TestPopover = (props) => (
Expand Down Expand Up @@ -176,6 +177,46 @@ describe('Popover', () => {
expect(arrow).toHaveAttribute('style', expect.stringContaining('top: 5px'));
});

describe('portalProvider', () => {
function InfoPopover() {
return (
<DialogTrigger>
<Button />
<Popover>
<OverlayArrow>
<svg width={12} height={12}>
<path d="M0 0,L6 6,L12 0" />
</svg>
</OverlayArrow>
<Dialog>Popover</Dialog>
</Popover>
</DialogTrigger>
);
}
function App() {
let container = useRef(null);
return (
<>
<UNSTABLE_PortalProvider getContainer={() => container.current}>
<InfoPopover container={container} />
</UNSTABLE_PortalProvider>
<div ref={container} data-testid="custom-container" />
</>
);
}
it('should render the dialog in the portal container set by the PortalProvider', async () => {
let {getByRole, getByTestId} = render(
<App />
);

let button = getByRole('button');
await user.click(button);

expect(getByRole('dialog').closest('[data-testid="custom-container"]')).toBe(getByTestId('custom-container'));
});
});

// TODO: delete this test when we get rid of the deprecated prop
describe('portalContainer', () => {
function InfoPopover(props) {
return (
Expand Down
21 changes: 12 additions & 9 deletions packages/react-aria-components/test/Toast.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,8 @@

import {act, installPointerEvent, pointerMap, render, within} from '@react-spectrum/test-utils-internal';
import {Button, Text, UNSTABLE_Toast as Toast, UNSTABLE_ToastContent as ToastContent, UNSTABLE_ToastQueue as ToastQueue, UNSTABLE_ToastRegion as ToastRegion} from 'react-aria-components';
import React from 'react';
import React, {useRef} from 'react';
import {UNSTABLE_PortalProvider} from '@react-aria/overlays';
import userEvent from '@testing-library/user-event';

function Example(options) {
Expand Down Expand Up @@ -183,7 +184,7 @@ describe('Toast', () => {
await user.click(closeButton);

expect(document.activeElement).toBe(button);

toast = getAllByRole('alertdialog')[0];
closeButton = within(toast).getByRole('button');
await user.click(closeButton);
Expand Down Expand Up @@ -323,13 +324,13 @@ describe('Toast', () => {
expect(region).toHaveAttribute('aria-label', 'Toasts');
});

it('should render the toast region in the portal container', async () => {
it('should render the toast region in the portal container provided by PortalProvider', async () => {
let queue = new ToastQueue();

function LocalToast(props) {
function LocalToast() {
return (
<>
<ToastRegion queue={queue} portalContainer={props.container}>
<ToastRegion queue={queue}>
{({toast}) => (
<Toast toast={toast}>
<ToastContent>
Expand All @@ -338,18 +339,20 @@ describe('Toast', () => {
<Button slot="close">x</Button>
</Toast>
)}
</ToastRegion>
</ToastRegion>

<Button onPress={() => queue.add('Toast')}>Add toast</Button>
</>
);
}
function App() {
let [container, setContainer] = React.useState();
let container = useRef(null);
return (
<>
<LocalToast container={container} />
<div ref={setContainer} data-testid="custom-container" />
<UNSTABLE_PortalProvider getContainer={() => container.current}>
<LocalToast container={container} />
</UNSTABLE_PortalProvider>
<div ref={container} data-testid="custom-container" />
</>
);
}
Expand Down
46 changes: 45 additions & 1 deletion packages/react-aria-components/test/Tooltip.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,8 @@

import {act, fireEvent, pointerMap, render} from '@react-spectrum/test-utils-internal';
import {Button, Focusable, OverlayArrow, Pressable, Tooltip, TooltipTrigger} from 'react-aria-components';
import React from 'react';
import React, {useRef} from 'react';
import {UNSTABLE_PortalProvider} from '@react-aria/overlays';
import userEvent from '@testing-library/user-event';

function TestTooltip(props) {
Expand Down Expand Up @@ -168,6 +169,49 @@ describe('Tooltip', () => {
expect(tooltip1).not.toBeVisible();
});

describe('portalProvider', () => {
function InfoTooltip(props) {
return (
<TooltipTrigger delay={0}>
<Button><span aria-hidden="true">✏️</span></Button>
<Tooltip data-test="tooltip" {...props}>
<OverlayArrow>
<svg width={8} height={8}>
<path d="M0 0,L4 4,L8 0" />
</svg>
</OverlayArrow>
Edit
</Tooltip>
</TooltipTrigger>
);
}
function App() {
let container = useRef(null);
return (
<>
<UNSTABLE_PortalProvider getContainer={() => container.current}>
<InfoTooltip container={container} />
</UNSTABLE_PortalProvider>
<div ref={container} data-testid="custom-container" />
</>
);
}
it('should render the tooltip in the portal container provided by the PortalProvider', async () => {
let {getByRole, getByTestId} = render(<App />);
let button = getByRole('button');

fireEvent.mouseMove(document.body);
await user.hover(button);
act(() => jest.runAllTimers());

expect(getByRole('tooltip').closest('[data-testid="custom-container"]')).toBe(getByTestId('custom-container'));

await user.unhover(button);
act(() => jest.runAllTimers());
});
});

// TODO: delete this test when we get rid of the deprecated prop
describe('portalContainer', () => {
function InfoTooltip(props) {
return (
Expand Down
1 change: 1 addition & 0 deletions yarn.lock
Original file line number Diff line number Diff line change
Expand Up @@ -29252,6 +29252,7 @@ __metadata:
"@react-aria/focus": "npm:^3.20.1"
"@react-aria/interactions": "npm:^3.24.1"
"@react-aria/live-announcer": "npm:^3.4.1"
"@react-aria/overlays": "npm:^3.26.1"
"@react-aria/ssr": "npm:^3.9.7"
"@react-aria/toolbar": "npm:3.0.0-beta.14"
"@react-aria/utils": "npm:^3.28.1"
Expand Down