Skip to content

Commit fd512d9

Browse files
authored
chore: Internal portal util (#129)
1 parent b062d4f commit fd512d9

File tree

3 files changed

+283
-0
lines changed

3 files changed

+283
-0
lines changed

src/internal/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,3 +36,4 @@ export {
3636
export { isFocusable, getAllFocusables, getFirstFocusable, getLastFocusable } from './focus-lock-utils/utils';
3737
export { default as handleKey } from './utils/handle-key';
3838
export { default as circleIndex } from './utils/circle-index';
39+
export { default as Portal, PortalProps } from './portal';
Lines changed: 210 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,210 @@
1+
// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
2+
// SPDX-License-Identifier: Apache-2.0
3+
4+
import React, { useState } from 'react';
5+
import { act, fireEvent, render, screen } from '@testing-library/react';
6+
7+
import { warnOnce } from '../../logging';
8+
import Portal, { PortalProps } from '../index';
9+
10+
function renderPortal(props: PortalProps) {
11+
const { rerender, unmount } = render(<Portal {...props} />);
12+
return { unmount, rerender: (props: PortalProps) => rerender(<Portal {...props} />) };
13+
}
14+
15+
jest.mock('../../logging', () => ({
16+
...jest.requireActual('../../logging'),
17+
warnOnce: jest.fn(),
18+
}));
19+
20+
afterEach(() => {
21+
expect(warnOnce).not.toHaveBeenCalled();
22+
jest.clearAllMocks();
23+
});
24+
25+
describe('Portal', () => {
26+
describe('when container is provided', () => {
27+
let container: Element | undefined;
28+
29+
beforeEach(() => {
30+
container = document.createElement('div');
31+
document.body.appendChild(container);
32+
});
33+
34+
afterEach(() => {
35+
container?.remove();
36+
});
37+
38+
test('renders to the container', () => {
39+
renderPortal({ children: <p>Hello!</p>, container });
40+
expect(container?.innerHTML).toBe('<p>Hello!</p>');
41+
});
42+
43+
test('ignores getContainer property', () => {
44+
const getContainer = jest.fn();
45+
const removeContainer = jest.fn();
46+
renderPortal({ children: <p>Hello!</p>, container, getContainer, removeContainer });
47+
expect(container!.innerHTML).toBe('<p>Hello!</p>');
48+
expect(getContainer).not.toHaveBeenCalled();
49+
expect(removeContainer).not.toHaveBeenCalled();
50+
});
51+
52+
test('cleans up react content inside container when unmounted', () => {
53+
const { unmount } = renderPortal({ children: <p>Hello!</p>, container });
54+
unmount();
55+
expect(container!.innerHTML).toBe('');
56+
});
57+
58+
test('cleans up react content inside container if an explicit container is no longer provided', () => {
59+
const { rerender } = renderPortal({ children: <p>Hello!</p>, container });
60+
rerender({ children: <p>Hello!</p> });
61+
expect(container!.innerHTML).toBe('');
62+
expect(document.body.textContent).toBe('Hello!');
63+
});
64+
});
65+
66+
describe('when getContainer/removeContainer property is provided', () => {
67+
test('falls back to default if only getContainer is provided', () => {
68+
const getContainer = jest.fn();
69+
renderPortal({ children: <p>Hello!</p>, getContainer });
70+
expect(getContainer).not.toHaveBeenCalled();
71+
expect(warnOnce).toHaveBeenCalledWith('portal', '`removeContainer` is required when `getContainer` is provided');
72+
jest.mocked(warnOnce).mockReset();
73+
});
74+
75+
test('falls back to default if only removeContainer is provided', () => {
76+
const removeContainer = jest.fn();
77+
renderPortal({ children: <p>Hello!</p>, removeContainer });
78+
expect(removeContainer).not.toHaveBeenCalled();
79+
expect(warnOnce).toHaveBeenCalledWith('portal', '`getContainer` is required when `removeContainer` is provided');
80+
jest.mocked(warnOnce).mockReset();
81+
});
82+
83+
test('renders and cleans up async container', async () => {
84+
const container = document.createElement('div');
85+
const getContainer = jest.fn(async () => {
86+
await Promise.resolve();
87+
document.body.appendChild(container);
88+
return container;
89+
});
90+
const removeContainer = jest.fn(element => document.body.removeChild(element));
91+
const { unmount } = renderPortal({
92+
children: <p data-testid="portal-content">Hello!</p>,
93+
getContainer,
94+
removeContainer,
95+
});
96+
expect(screen.queryByTestId('portal-content')).toBeFalsy();
97+
expect(document.body.contains(container)).toBe(false);
98+
expect(getContainer).toHaveBeenCalled();
99+
100+
// wait a tick to resolve pending promises
101+
await act(() => Promise.resolve());
102+
expect(document.body.contains(container)).toBe(true);
103+
expect(container.contains(screen.queryByTestId('portal-content'))).toBe(true);
104+
105+
unmount();
106+
expect(removeContainer).toHaveBeenCalledWith(container);
107+
expect(screen.queryByTestId('portal-content')).toBeFalsy();
108+
expect(document.body.contains(container)).toBe(false);
109+
});
110+
111+
test('allows conditional change of getContainer/removeContainer', async () => {
112+
function MovablePortal({ getContainer, removeContainer }: Pick<PortalProps, 'getContainer' | 'removeContainer'>) {
113+
const [visible, setVisible] = useState(false);
114+
return (
115+
<>
116+
<button data-testid="toggle-portal" onClick={() => setVisible(!visible)}>
117+
Toggle
118+
</button>
119+
<Portal
120+
getContainer={visible ? getContainer : undefined}
121+
removeContainer={visible ? removeContainer : undefined}
122+
>
123+
<div data-testid="portal-content">portal content</div>
124+
</Portal>
125+
</>
126+
);
127+
}
128+
129+
const iframe = document.createElement('iframe');
130+
document.body.appendChild(iframe);
131+
const externalDocument = iframe.contentDocument!;
132+
133+
const getContainer = jest.fn(() => {
134+
const container = externalDocument.createElement('div');
135+
container.setAttribute('data-testid', 'dynamic-container');
136+
externalDocument.body.appendChild(container);
137+
return Promise.resolve(container);
138+
});
139+
140+
const removeContainer = jest.fn(() => {
141+
const allContainers = externalDocument.querySelectorAll('[data-testid="dynamic-container"]');
142+
expect(allContainers).toHaveLength(1);
143+
allContainers[0].remove();
144+
});
145+
146+
render(<MovablePortal getContainer={getContainer} removeContainer={removeContainer} />);
147+
expect(document.body.contains(screen.getByTestId('portal-content'))).toBe(true);
148+
expect(getContainer).not.toHaveBeenCalled();
149+
expect(removeContainer).not.toHaveBeenCalled();
150+
151+
fireEvent.click(screen.getByTestId('toggle-portal'));
152+
// wait a tick to resolve pending promises
153+
await act(() => Promise.resolve());
154+
expect(screen.queryByTestId('portal-content')).toBeFalsy();
155+
expect(externalDocument.querySelector('[data-testid="portal-content"]')).toBeTruthy();
156+
expect(externalDocument.querySelectorAll('[data-testid="dynamic-container"]')).toHaveLength(1);
157+
expect(getContainer).toHaveBeenCalledTimes(1);
158+
expect(removeContainer).toHaveBeenCalledTimes(0);
159+
160+
fireEvent.click(screen.getByTestId('toggle-portal'));
161+
// wait a tick to resolve pending promises
162+
await act(() => Promise.resolve());
163+
expect(document.body.contains(screen.getByTestId('portal-content'))).toBe(true);
164+
expect(externalDocument.querySelector('[data-testid="portal-content"]')).toBeFalsy();
165+
expect(externalDocument.querySelectorAll('[data-testid="dynamic-container"]')).toHaveLength(0);
166+
expect(getContainer).toHaveBeenCalledTimes(1);
167+
expect(removeContainer).toHaveBeenCalledTimes(1);
168+
});
169+
170+
describe('console logging', () => {
171+
beforeEach(() => {
172+
jest.spyOn(console, 'warn').mockImplementation(() => {});
173+
});
174+
175+
test('prints a warning if getContainer rejects a promise', async () => {
176+
const getContainer = jest.fn(() => Promise.reject('Error for testing'));
177+
const removeContainer = jest.fn(() => {});
178+
renderPortal({
179+
children: <p data-testid="portal-content">Hello!</p>,
180+
getContainer,
181+
removeContainer,
182+
});
183+
expect(screen.queryByTestId('portal-content')).toBeFalsy();
184+
expect(getContainer).toHaveBeenCalled();
185+
expect(console.warn).not.toHaveBeenCalled();
186+
187+
await Promise.resolve();
188+
expect(screen.queryByTestId('portal-content')).toBeFalsy();
189+
expect(removeContainer).not.toHaveBeenCalled();
190+
expect(console.warn).toHaveBeenCalledWith('[AwsUi] [portal]: failed to load portal root', 'Error for testing');
191+
});
192+
});
193+
});
194+
195+
describe('when a container is not provided', () => {
196+
test('renders to a div under body', () => {
197+
renderPortal({ children: <p>Hello!</p> });
198+
expect(document.querySelector('body > div > p')?.textContent).toBe('Hello!');
199+
});
200+
201+
test('removes container element when unmounted', () => {
202+
const { unmount } = renderPortal({ children: <p>Hello!</p> });
203+
// The extra <div> is a wrapper element that react-testing-library creates.
204+
expect(document.querySelectorAll('body > div').length).toBe(2);
205+
unmount();
206+
expect(document.querySelectorAll('body > div').length).toBe(1);
207+
expect(document.querySelector('body > div')?.innerHTML).toBe('');
208+
});
209+
});
210+
});

src/internal/portal/index.tsx

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
2+
// SPDX-License-Identifier: Apache-2.0
3+
4+
import React, { useLayoutEffect, useState } from 'react';
5+
import { createPortal } from 'react-dom';
6+
import { isDevelopment } from '../is-development';
7+
import { warnOnce } from '../logging';
8+
9+
export interface PortalProps {
10+
container?: null | Element;
11+
getContainer?: () => Promise<HTMLElement>;
12+
removeContainer?: (container: HTMLElement) => void;
13+
children: React.ReactNode;
14+
}
15+
16+
function manageDefaultContainer(setState: React.Dispatch<React.SetStateAction<Element | null>>) {
17+
const newContainer = document.createElement('div');
18+
document.body.appendChild(newContainer);
19+
setState(newContainer);
20+
return () => {
21+
document.body.removeChild(newContainer);
22+
};
23+
}
24+
25+
function manageAsyncContainer(
26+
getContainer: () => Promise<HTMLElement>,
27+
removeContainer: (container: HTMLElement) => void,
28+
setState: React.Dispatch<React.SetStateAction<Element | null>>
29+
) {
30+
let newContainer: HTMLElement;
31+
getContainer().then(
32+
container => {
33+
newContainer = container;
34+
setState(container);
35+
},
36+
error => {
37+
console.warn('[AwsUi] [portal]: failed to load portal root', error);
38+
}
39+
);
40+
return () => {
41+
removeContainer(newContainer);
42+
};
43+
}
44+
45+
/**
46+
* A safe react portal component that renders to a provided node.
47+
* If a node isn't provided, it creates one under document.body.
48+
*/
49+
export default function Portal({ container, getContainer, removeContainer, children }: PortalProps) {
50+
const [activeContainer, setActiveContainer] = useState<Element | null>(container ?? null);
51+
52+
useLayoutEffect(() => {
53+
if (container) {
54+
setActiveContainer(container);
55+
return;
56+
}
57+
if (isDevelopment) {
58+
if (getContainer && !removeContainer) {
59+
warnOnce('portal', '`removeContainer` is required when `getContainer` is provided');
60+
}
61+
if (!getContainer && removeContainer) {
62+
warnOnce('portal', '`getContainer` is required when `removeContainer` is provided');
63+
}
64+
}
65+
if (getContainer && removeContainer) {
66+
return manageAsyncContainer(getContainer, removeContainer, setActiveContainer);
67+
}
68+
return manageDefaultContainer(setActiveContainer);
69+
}, [container, getContainer, removeContainer]);
70+
71+
return activeContainer && createPortal(children, activeContainer);
72+
}

0 commit comments

Comments
 (0)