import * as React from 'react';
import * as ReactDOM from 'react-dom';
// Internally, the portalNode must be for either HTML or SVG elements
const ELEMENT_TYPE_HTML = 'html';
const ELEMENT_TYPE_SVG = 'svg';
type BaseOptions = {
attributes?: { [key: string]: string };
};
type HtmlOptions = BaseOptions & {
containerElement?: keyof HTMLElementTagNameMap;
};
type SvgOptions = BaseOptions & {
containerElement?: keyof SVGElementTagNameMap;
};
type Options = HtmlOptions | SvgOptions;
// ReactDOM can handle several different namespaces, but they're not exported publicly
// https://github.com/facebook/react/blob/b87aabdfe1b7461e7331abb3601d9e6bb27544bc/packages/react-dom/src/shared/DOMNamespaces.js#L8-L10
const SVG_NAMESPACE = 'http://www.w3.org/2000/svg';
type Component
= React.Component
| React.ComponentType
;
type ComponentProps> = C extends Component ? P : never;
interface PortalNodeBase> {
// Used by the out portal to send props back to the real element
// Hooked by InPortal to become a state update (and thus rerender)
setPortalProps(p: ComponentProps): void;
// Used to track props set before the InPortal hooks setPortalProps
getInitialPortalProps(): ComponentProps;
// Move the node from wherever it is, to this parent, replacing the placeholder
mount(newParent: Node, placeholder: Node): void;
// If mounted, unmount the node and put the initial placeholder back
// If an expected placeholder is provided, only unmount if that's still that was the
// latest placeholder we replaced. This avoids some race conditions.
unmount(expectedPlaceholder?: Node): void;
}
export interface HtmlPortalNode = Component> extends PortalNodeBase {
element: HTMLElement;
elementType: typeof ELEMENT_TYPE_HTML;
}
export interface SvgPortalNode = Component> extends PortalNodeBase {
element: SVGElement;
elementType: typeof ELEMENT_TYPE_SVG;
}
type AnyPortalNode = Component> = HtmlPortalNode | SvgPortalNode;
const validateElementType = (domElement: Element, elementType: typeof ELEMENT_TYPE_HTML | typeof ELEMENT_TYPE_SVG) => {
const ownerDocument = (domElement.ownerDocument ?? document) as any;
// Cast document to `any` because Typescript doesn't know about the legacy `Document.parentWindow` field, and also
// doesn't believe `Window.HTMLElement`/`Window.SVGElement` can be used in instanceof tests.
const ownerWindow = ownerDocument.defaultView ?? ownerDocument.parentWindow ?? window; // `parentWindow` for IE8 and earlier
switch (elementType) {
case ELEMENT_TYPE_HTML:
return domElement instanceof ownerWindow.HTMLElement;
case ELEMENT_TYPE_SVG:
return domElement instanceof ownerWindow.SVGElement;
default:
throw new Error(`Unrecognized element type "${elementType}" for validateElementType.`);
}
};
// This is the internal implementation: the public entry points set elementType to an appropriate value
const createPortalNode = >(
elementType: typeof ELEMENT_TYPE_HTML | typeof ELEMENT_TYPE_SVG,
options?: Options
): AnyPortalNode => {
let initialProps = {} as ComponentProps;
let parent: Node | undefined;
let lastPlaceholder: Node | undefined;
let element;
switch (elementType) {
case ELEMENT_TYPE_HTML:
element = document.createElement(options?.containerElement ?? 'div');
break;
case ELEMENT_TYPE_SVG:
element = document.createElementNS(SVG_NAMESPACE, options?.containerElement ?? 'g');
break;
default:
throw new Error(`Invalid element type "${elementType}" for createPortalNode: must be "html" or "svg".`);
}
if (options && typeof options === "object" && options.attributes) {
for (const [key, value] of Object.entries(options.attributes)) {
element.setAttribute(key, value);
}
}
const portalNode: AnyPortalNode = {
element,
elementType,
setPortalProps: (props: ComponentProps) => {
initialProps = props;
},
getInitialPortalProps: () => {
return initialProps;
},
mount: (newParent: HTMLElement, newPlaceholder: HTMLElement) => {
if (newPlaceholder === lastPlaceholder) {
// Already mounted - noop.
return;
}
portalNode.unmount();
// To support SVG and other non-html elements, the portalNode's elementType needs to match
// the elementType it's being rendered into
if (newParent !== parent) {
if (!validateElementType(newParent, elementType)) {
throw new Error(`Invalid element type for portal: "${elementType}" portalNodes must be used with ${elementType} elements, but OutPortal is within <${newParent.tagName}>.`);
}
}
newParent.replaceChild(
portalNode.element,
newPlaceholder,
);
parent = newParent;
lastPlaceholder = newPlaceholder;
},
unmount: (expectedPlaceholder?: Node) => {
if (expectedPlaceholder && expectedPlaceholder !== lastPlaceholder) {
// Skip unmounts for placeholders that aren't currently mounted
// They will have been automatically unmounted already by a subsequent mount()
return;
}
if (parent && lastPlaceholder) {
parent.replaceChild(
lastPlaceholder,
portalNode.element,
);
parent = undefined;
lastPlaceholder = undefined;
}
}
} as AnyPortalNode;
return portalNode;
};
interface InPortalProps {
node: AnyPortalNode;
children: React.ReactNode;
}
class InPortal extends React.PureComponent {
constructor(props: InPortalProps) {
super(props);
this.state = {
nodeProps: this.props.node.getInitialPortalProps(),
};
}
addPropsChannel = () => {
Object.assign(this.props.node, {
setPortalProps: (props: {}) => {
// Rerender the child node here if/when the out portal props change
this.setState({ nodeProps: props });
}
});
};
componentDidMount() {
this.addPropsChannel();
}
componentDidUpdate() {
this.addPropsChannel();
}
render() {
const { children, node } = this.props;
return ReactDOM.createPortal(
React.Children.map(children, (child) => {
if (!React.isValidElement(child)) return child;
return React.cloneElement(child, this.state.nodeProps)
}),
node.element
);
}
}
type OutPortalProps> = {
node: AnyPortalNode
} & Partial>;
class OutPortal> extends React.PureComponent> {
private placeholderNode = React.createRef();
private currentPortalNode?: AnyPortalNode;
constructor(props: OutPortalProps) {
super(props);
this.passPropsThroughPortal();
}
passPropsThroughPortal() {
const propsForTarget = Object.assign({}, this.props, { node: undefined });
this.props.node.setPortalProps(propsForTarget);
}
componentDidMount() {
const node = this.props.node as AnyPortalNode;
this.currentPortalNode = node;
const placeholder = this.placeholderNode.current!;
const parent = placeholder.parentNode!;
node.mount(parent, placeholder);
this.passPropsThroughPortal();
}
componentDidUpdate() {
// We re-mount on update, just in case we were unmounted (e.g. by
// a second OutPortal, which has now been removed)
const node = this.props.node as AnyPortalNode;
// If we're switching portal nodes, we need to clean up the current one first.
if (this.currentPortalNode && node !== this.currentPortalNode) {
this.currentPortalNode.unmount(this.placeholderNode.current!);
this.currentPortalNode.setPortalProps({} as ComponentProps);
this.currentPortalNode = node;
}
const placeholder = this.placeholderNode.current!;
const parent = placeholder.parentNode!;
node.mount(parent, placeholder);
this.passPropsThroughPortal();
}
componentWillUnmount() {
const node = this.props.node as AnyPortalNode;
node.unmount(this.placeholderNode.current!);
node.setPortalProps({} as ComponentProps);
}
render() {
// Render a placeholder to the DOM, so we can get a reference into
// our location in the DOM, and swap it out for the portaled node.
const tagName = this.props.node.element.tagName;
// SVG tagName is lowercase and case sensitive, HTML is uppercase and case insensitive.
// React.createElement expects lowercase first letter to treat as non-component element.
// (Passing uppercase type won't break anything, but React warns otherwise:)
// https://github.com/facebook/react/blob/8039f1b2a05d00437cd29707761aeae098c80adc/CHANGELOG.md?plain=1#L1984
const type = this.props.node.elementType === ELEMENT_TYPE_HTML
? tagName.toLowerCase()
: tagName;
return React.createElement(type, { ref: this.placeholderNode });
}
}
const createHtmlPortalNode = createPortalNode.bind(null, ELEMENT_TYPE_HTML) as
= Component>(options?: HtmlOptions) => HtmlPortalNode;
const createSvgPortalNode = createPortalNode.bind(null, ELEMENT_TYPE_SVG) as
= Component>(options?: SvgOptions) => SvgPortalNode;
export {
createHtmlPortalNode,
createSvgPortalNode,
InPortal,
OutPortal,
}