import React, { useEffect, useRef, useState } from 'react';
import ReactDOM from 'react-dom';
import type { ReactComponentOrElement } from '../models';
interface IonOverlayManagerProps {
onAddOverlay: (
callback: (id: string, component: ReactComponentOrElement, containerElement: HTMLDivElement) => void
) => void;
onRemoveOverlay: (callback: (id: string) => void) => void;
}
/**
* Manages overlays that are added via the useOverlay hook.
* This is a standalone component so changes to its children don't cause other descendant
* components to re-render when overlays are added. However, we need to communicate with the IonContext
* that is set up in , so we register callbacks so when overlays are added to IonContext,
* they ultimately added here.
*/
export const IonOverlayManager: React.FC = ({ onAddOverlay, onRemoveOverlay }) => {
type OverlaysList = {
[key: string]: {
component: any; // TODO(FW-2959): type
containerElement: HTMLDivElement;
};
};
/**
* Because of the way we're passing around the addOverlay and removeOverlay
* callbacks, by the time they finally get called, they use a stale reference
* to the state that only has the initial values. So if two overlays are opened
* at the same time, both using useIonModal or similar (such as through nesting),
* the second will erase the first from the overlays list. This causes the content
* of the first overlay to unmount.
*
* We wrap the state in useRef to ensure the two callbacks always use the most
* up-to-date version.
*
* Further reading: https://stackoverflow.com/a/56554056
*/
const [overlays, setOverlays] = useState({});
const overlaysRef = useRef({});
useEffect(() => {
/* Setup the callbacks that get called from */
onAddOverlay(addOverlay);
onRemoveOverlay(removeOverlay);
}, []);
const addOverlay = (id: string, component: ReactComponentOrElement, containerElement: HTMLDivElement) => {
const newOverlays = { ...overlaysRef.current };
newOverlays[id] = { component, containerElement };
/**
* In order for this function to use the latest data
* we need to update the ref synchronously.
* However, updating a ref does not cause a re-render
* which is why we still update the state.
*
* Note that updating the ref in the body
* of IonOverlayManager is not sufficient
* because that relies on overlaysRef being
* updated as part of React's render loop. State updates
* are batched, so updating the state twice in quick succession does
* not necessarily result in 2 separate render calls.
* This means if two modals are added one after the other,
* the first modal will not have been added to
* overlaysRef since React has not re-rendered yet.
* More info: https://react.dev/reference/react/useState#setstate-caveats
*/
overlaysRef.current = newOverlays;
setOverlays(newOverlays);
};
const removeOverlay = (id: string) => {
const newOverlays = { ...overlaysRef.current };
delete newOverlays[id];
/**
* In order for this function to use the latest data
* we need to update the ref synchronously.
* However, updating a ref does not cause a re-render
* which is why we still update the state.
*
* Note that updating the ref in the body
* of IonOverlayManager is not sufficient
* because that relies on overlaysRef being
* updated as part of React's render loop. State updates
* are batched, so updating the state twice in quick succession does
* not necessarily result in 2 separate render calls.
* This means if two modals are added one after the other,
* the first modal will not have been added to
* overlaysRef since React has not re-rendered yet.
* More info: https://react.dev/reference/react/useState#setstate-caveats
*/
overlaysRef.current = newOverlays;
setOverlays(newOverlays);
};
const overlayKeys = Object.keys(overlays);
return (
<>
{overlayKeys.map((key) => {
const overlay = overlays[key];
return ReactDOM.createPortal(overlay.component, overlay.containerElement, `overlay-${key}`);
})}
>
);
};