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 ANY_ELEMENT_TYPE = typeof ELEMENT_TYPE_HTML | typeof ELEMENT_TYPE_SVG; // 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: ANY_ELEMENT_TYPE) => { if (elementType === ELEMENT_TYPE_HTML) { return domElement instanceof HTMLElement; } if (elementType === ELEMENT_TYPE_SVG) { return domElement instanceof SVGElement; } 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: ANY_ELEMENT_TYPE): AnyPortalNode => { let initialProps = {} as ComponentProps; let parent: Node | undefined; let lastPlaceholder: Node | undefined; let element; if (elementType === ELEMENT_TYPE_HTML) { element= document.createElement('div'); } else if (elementType === ELEMENT_TYPE_SVG){ element= document.createElementNS(SVG_NAMESPACE, 'g'); } else { throw new Error(`Invalid element type "${elementType}" for createPortalNode: must be "html" or "svg".`); } 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 = 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!); } 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. // A

placeholder works fine even for SVG. return
; } } const createHtmlPortalNode = createPortalNode.bind(null, ELEMENT_TYPE_HTML) as = Component>() => HtmlPortalNode; const createSvgPortalNode = createPortalNode.bind(null, ELEMENT_TYPE_SVG) as = Component>() => SvgPortalNode; export { createHtmlPortalNode, createSvgPortalNode, InPortal, OutPortal, }