Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions src/internal/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,3 +36,4 @@ export {
export { isFocusable, getAllFocusables, getFirstFocusable, getLastFocusable } from './focus-lock-utils/utils';
export { default as handleKey } from './utils/handle-key';
export { default as circleIndex } from './utils/circle-index';
export { default as Portal, PortalProps } from './portal';
210 changes: 210 additions & 0 deletions src/internal/portal/__tests__/portal.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,210 @@
// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
// SPDX-License-Identifier: Apache-2.0

import React, { useState } from 'react';
import { act, fireEvent, render, screen } from '@testing-library/react';

import { warnOnce } from '../../logging';
import Portal, { PortalProps } from '../index';

function renderPortal(props: PortalProps) {
const { rerender, unmount } = render(<Portal {...props} />);
return { unmount, rerender: (props: PortalProps) => rerender(<Portal {...props} />) };
}

jest.mock('../../logging', () => ({
...jest.requireActual('../../logging'),
warnOnce: jest.fn(),
}));

afterEach(() => {
expect(warnOnce).not.toHaveBeenCalled();
jest.clearAllMocks();
});

describe('Portal', () => {
describe('when container is provided', () => {
let container: Element | undefined;

beforeEach(() => {
container = document.createElement('div');
document.body.appendChild(container);
});

afterEach(() => {
container?.remove();
});

test('renders to the container', () => {
renderPortal({ children: <p>Hello!</p>, container });
expect(container?.innerHTML).toBe('<p>Hello!</p>');
});

test('ignores getContainer property', () => {
const getContainer = jest.fn();
const removeContainer = jest.fn();
renderPortal({ children: <p>Hello!</p>, container, getContainer, removeContainer });
expect(container!.innerHTML).toBe('<p>Hello!</p>');
expect(getContainer).not.toHaveBeenCalled();
expect(removeContainer).not.toHaveBeenCalled();
});

test('cleans up react content inside container when unmounted', () => {
const { unmount } = renderPortal({ children: <p>Hello!</p>, container });
unmount();
expect(container!.innerHTML).toBe('');
});

test('cleans up react content inside container if an explicit container is no longer provided', () => {
const { rerender } = renderPortal({ children: <p>Hello!</p>, container });
rerender({ children: <p>Hello!</p> });
expect(container!.innerHTML).toBe('');
expect(document.body.textContent).toBe('Hello!');
});
});

describe('when getContainer/removeContainer property is provided', () => {
test('falls back to default if only getContainer is provided', () => {
const getContainer = jest.fn();
renderPortal({ children: <p>Hello!</p>, getContainer });
expect(getContainer).not.toHaveBeenCalled();
expect(warnOnce).toHaveBeenCalledWith('portal', '`removeContainer` is required when `getContainer` is provided');
jest.mocked(warnOnce).mockReset();
});

test('falls back to default if only removeContainer is provided', () => {
const removeContainer = jest.fn();
renderPortal({ children: <p>Hello!</p>, removeContainer });
expect(removeContainer).not.toHaveBeenCalled();
expect(warnOnce).toHaveBeenCalledWith('portal', '`getContainer` is required when `removeContainer` is provided');
jest.mocked(warnOnce).mockReset();
});

test('renders and cleans up async container', async () => {
const container = document.createElement('div');
const getContainer = jest.fn(async () => {
await Promise.resolve();
document.body.appendChild(container);
return container;
});
const removeContainer = jest.fn(element => document.body.removeChild(element));
const { unmount } = renderPortal({
children: <p data-testid="portal-content">Hello!</p>,
getContainer,
removeContainer,
});
expect(screen.queryByTestId('portal-content')).toBeFalsy();
expect(document.body.contains(container)).toBe(false);
expect(getContainer).toHaveBeenCalled();

// wait a tick to resolve pending promises
await act(() => Promise.resolve());
expect(document.body.contains(container)).toBe(true);
expect(container.contains(screen.queryByTestId('portal-content'))).toBe(true);

unmount();
expect(removeContainer).toHaveBeenCalledWith(container);
expect(screen.queryByTestId('portal-content')).toBeFalsy();
expect(document.body.contains(container)).toBe(false);
});

test('allows conditional change of getContainer/removeContainer', async () => {
function MovablePortal({ getContainer, removeContainer }: Pick<PortalProps, 'getContainer' | 'removeContainer'>) {
const [visible, setVisible] = useState(false);
return (
<>
<button data-testid="toggle-portal" onClick={() => setVisible(!visible)}>
Toggle
</button>
<Portal
getContainer={visible ? getContainer : undefined}
removeContainer={visible ? removeContainer : undefined}
>
<div data-testid="portal-content">portal content</div>
</Portal>
</>
);
}

const iframe = document.createElement('iframe');
document.body.appendChild(iframe);
const externalDocument = iframe.contentDocument!;

const getContainer = jest.fn(() => {
const container = externalDocument.createElement('div');
container.setAttribute('data-testid', 'dynamic-container');
externalDocument.body.appendChild(container);
return Promise.resolve(container);
});

const removeContainer = jest.fn(() => {
const allContainers = externalDocument.querySelectorAll('[data-testid="dynamic-container"]');
expect(allContainers).toHaveLength(1);
allContainers[0].remove();
});

render(<MovablePortal getContainer={getContainer} removeContainer={removeContainer} />);
expect(document.body.contains(screen.getByTestId('portal-content'))).toBe(true);
expect(getContainer).not.toHaveBeenCalled();
expect(removeContainer).not.toHaveBeenCalled();

fireEvent.click(screen.getByTestId('toggle-portal'));
// wait a tick to resolve pending promises
await act(() => Promise.resolve());
expect(screen.queryByTestId('portal-content')).toBeFalsy();
expect(externalDocument.querySelector('[data-testid="portal-content"]')).toBeTruthy();
expect(externalDocument.querySelectorAll('[data-testid="dynamic-container"]')).toHaveLength(1);
expect(getContainer).toHaveBeenCalledTimes(1);
expect(removeContainer).toHaveBeenCalledTimes(0);

fireEvent.click(screen.getByTestId('toggle-portal'));
// wait a tick to resolve pending promises
await act(() => Promise.resolve());
expect(document.body.contains(screen.getByTestId('portal-content'))).toBe(true);
expect(externalDocument.querySelector('[data-testid="portal-content"]')).toBeFalsy();
expect(externalDocument.querySelectorAll('[data-testid="dynamic-container"]')).toHaveLength(0);
expect(getContainer).toHaveBeenCalledTimes(1);
expect(removeContainer).toHaveBeenCalledTimes(1);
});

describe('console logging', () => {
beforeEach(() => {
jest.spyOn(console, 'warn').mockImplementation(() => {});
});

test('prints a warning if getContainer rejects a promise', async () => {
const getContainer = jest.fn(() => Promise.reject('Error for testing'));
const removeContainer = jest.fn(() => {});
renderPortal({
children: <p data-testid="portal-content">Hello!</p>,
getContainer,
removeContainer,
});
expect(screen.queryByTestId('portal-content')).toBeFalsy();
expect(getContainer).toHaveBeenCalled();
expect(console.warn).not.toHaveBeenCalled();

await Promise.resolve();
expect(screen.queryByTestId('portal-content')).toBeFalsy();
expect(removeContainer).not.toHaveBeenCalled();
expect(console.warn).toHaveBeenCalledWith('[AwsUi] [portal]: failed to load portal root', 'Error for testing');
});
});
});

describe('when a container is not provided', () => {
test('renders to a div under body', () => {
renderPortal({ children: <p>Hello!</p> });
expect(document.querySelector('body > div > p')?.textContent).toBe('Hello!');
});

test('removes container element when unmounted', () => {
const { unmount } = renderPortal({ children: <p>Hello!</p> });
// The extra <div> is a wrapper element that react-testing-library creates.
expect(document.querySelectorAll('body > div').length).toBe(2);
unmount();
expect(document.querySelectorAll('body > div').length).toBe(1);
expect(document.querySelector('body > div')?.innerHTML).toBe('');
});
});
});
72 changes: 72 additions & 0 deletions src/internal/portal/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
// SPDX-License-Identifier: Apache-2.0

import React, { useLayoutEffect, useState } from 'react';
import { createPortal } from 'react-dom';
import { isDevelopment } from '../is-development';
import { warnOnce } from '../logging';

export interface PortalProps {
container?: null | Element;
getContainer?: () => Promise<HTMLElement>;
removeContainer?: (container: HTMLElement) => void;
children: React.ReactNode;
}

function manageDefaultContainer(setState: React.Dispatch<React.SetStateAction<Element | null>>) {
const newContainer = document.createElement('div');
document.body.appendChild(newContainer);
setState(newContainer);
return () => {
document.body.removeChild(newContainer);
};
}

function manageAsyncContainer(
getContainer: () => Promise<HTMLElement>,
removeContainer: (container: HTMLElement) => void,
setState: React.Dispatch<React.SetStateAction<Element | null>>
) {
let newContainer: HTMLElement;
getContainer().then(
container => {
newContainer = container;
setState(container);
},
error => {
console.warn('[AwsUi] [portal]: failed to load portal root', error);
}
);
return () => {
removeContainer(newContainer);
};
}

/**
* A safe react portal component that renders to a provided node.
* If a node isn't provided, it creates one under document.body.
*/
export default function Portal({ container, getContainer, removeContainer, children }: PortalProps) {
const [activeContainer, setActiveContainer] = useState<Element | null>(container ?? null);

useLayoutEffect(() => {
if (container) {
setActiveContainer(container);
return;
}
if (isDevelopment) {
if (getContainer && !removeContainer) {
warnOnce('portal', '`removeContainer` is required when `getContainer` is provided');
}
if (!getContainer && removeContainer) {
warnOnce('portal', '`getContainer` is required when `removeContainer` is provided');
}
}
if (getContainer && removeContainer) {
return manageAsyncContainer(getContainer, removeContainer, setActiveContainer);
}
return manageDefaultContainer(setActiveContainer);
}, [container, getContainer, removeContainer]);

return activeContainer && createPortal(children, activeContainer);
}
Loading