import { Fragment, type ReactNode, useCallback, useLayoutEffect, useRef, useState, useSyncExternalStore } from 'react';
import { Modal, Platform, View } from 'react-native';
import { useFocusTrap } from '../../../hooks/use-focus-trap';
import { useModalRender } from '../../../hooks/use-modal-render';
import { useMountEffect } from '../../../hooks/use-mount-effect';
import { OverlayOutlet } from './overlay-portal';
import {
getLayerDepth,
getOverlayStack,
isBottomLayer,
removeOverlayLayer,
resolveLayerLifetime,
subscribeOverlayStack,
upsertOverlayLayer,
} from './overlay-portal-store';
// The bottom (Modal-owning) layer fills the window; guest layers overlay it
// absolutely so they stack on top in document order inside the single Modal.
const FILL = { flex: 1 } as const;
const OVERLAY = { position: 'absolute', top: 0, right: 0, bottom: 0, left: 0 } as const;
type OwnerModalProps = { layerId: number; renderLayer: () => ReactNode; onShow?: () => void; fallbackClose: () => void };
/**
* The bottom layer owns the single Modal and renders every layer in registration
* order (bottom → top), each keeping its own AnimatePresence + MotiView exit.
*/
function OwnerModal({ layerId, renderLayer, onShow, fallbackClose }: OwnerModalProps) {
const layers = useSyncExternalStore(subscribeOverlayStack, getOverlayStack);
// The hardware back button should dismiss the topmost (visible) layer, not
// the bottom one.
const topLayer = layers.at(-1);
return (
{renderLayer()}
{layers
.filter((layer) => layer.id !== layerId)
.map((layer) => (
{layer.render()}
))}
);
}
type GuestContentProps = { layerId: number; render: () => ReactNode; exit: () => void; close: () => void; onShow?: () => void };
/**
* A guest overlay renders nothing at its call site: its content lives in the
* layer stack and is drawn by the owner's Modal. This refreshes that store
* render whenever the guest re-renders so the owner repaints it — e.g. when the
* guest's exit animation begins.
*/
function GuestContent({ layerId, render, exit, close, onShow }: GuestContentProps) {
useLayoutEffect(() => {
upsertOverlayLayer({ id: layerId, render, onExitComplete: exit, onRequestClose: close });
}, [layerId, render, exit, close]);
// Guest layers have no native presentation of their own, so their `onShow` is
// emitted from their enter — one frame after they open, once the owner has
// painted them. iOS-only, mirroring `Modal.onShow`.
useMountEffect(() => {
if (Platform.OS !== 'ios' || !onShow) return;
const raf = requestAnimationFrame(() => onShow());
return () => cancelAnimationFrame(raf);
});
return null;
}
export type OverlayShellContext = {
/** Whether the overlay is currently open (drives AnimatePresence children). */
open: boolean;
/** Call this when the exit animation completes to release the modal mount. */
onExitComplete: () => void;
};
export type OverlayShellProps = {
open: boolean;
onClose: () => void;
/** Handle system back/Escape separately from explicit dismissal. */
onRequestClose?: () => void;
/** Called after the exit animation fully completes. */
onAfterClose?: () => void;
/** When false, hardware back-button / request-close is ignored. Default true. */
dismissable?: boolean;
/**
* Names the dialog for assistive technology. Overlays that render a visible
* title should pass it here so the announcement matches what is on screen.
*/
accessibilityLabel?: string;
/**
* Fires after the modal has fully presented (iOS `Modal.onShow`) — the moment
* it is safe to request keyboard focus on content inside the overlay. No-op on
* web, where `Modal` renders in place and `autoFocus` already works.
*/
onShow?: () => void;
/** Render prop receiving { open, onExitComplete } to drive AnimatePresence. */
children: (ctx: OverlayShellContext) => ReactNode;
};
/**
* Shared overlay boilerplate: Modal + useModalRender mount lifecycle + a11y props.
*
* Each overlay passes a render-prop child that receives `{ open, onExitComplete }`.
* The child drives `` so the modal
* stays mounted until its exit animation settles.
*
* N overlays open at once collapse into **one** native `Modal`: the bottom
* (first-registered) layer owns the Modal and renders every layer in order;
* later layers register as guests and render nothing at their call site. This
* sidesteps iOS's `presentViewController:` dropping a second presentation onto a
* VC that is already presenting, so a confirm dialog raised from a settings
* sheet appears on top of it.
*
* The bottom layer stays registered after its own content has exited, for as long
* as layers are still open above it — it becomes a host drawing only those
* layers, so the Modal is never unmounted (or remounted) out from under them. It
* leaves once the stack empties, which is when the Modal unmounts.
*
* @example
*
* {({ open, onExitComplete }) => (
*
* {open ? ... : null}
*
* )}
*
*/
export function OverlayShell({
open,
onClose,
onRequestClose,
onAfterClose,
dismissable = true,
accessibilityLabel,
onShow,
children,
}: OverlayShellProps) {
const { rendered, onExitComplete } = useModalRender(open);
const contentRef = useRef(null);
// Native gets containment from Modal + accessibilityViewIsModal; on web the
// Modal is a plain fixed div and Tab would walk out into the page behind it.
useFocusTrap(contentRef, open);
const handleExitComplete = useCallback(() => {
onExitComplete();
onAfterClose?.();
}, [onExitComplete, onAfterClose]);
const handleRequestClose = useCallback(() => {
if (dismissable) (onRequestClose ?? onClose)();
}, [dismissable, onRequestClose, onClose]);
// Stable wrappers so the store's layer entry (registered once) always reads
// the current handlers without needing a refresh on every render.
const exitRef = useRef(handleExitComplete);
exitRef.current = handleExitComplete;
const closeRef = useRef(handleRequestClose);
closeRef.current = handleRequestClose;
const stableExit = useCallback(() => exitRef.current(), []);
const stableClose = useCallback(() => closeRef.current(), []);
const [layerId, setLayerId] = useState(null);
// Mirrors `layerId` but stays current inside the mount-only unmount cleanup,
// where the `layerId` closure would be stale (null from the first render).
const layerIdRef = useRef(null);
// Ownership and registration lifetime are read from the live stack on every
// render, never latched when the layer registers. A latched answer goes stale
// the moment the stack changes underneath it: the bottom layer leaving while
// layers above it are still open takes the Modal it owns — and everything
// drawn inside it — down with it.
const getIsBottom = useCallback(() => isBottomLayer(layerId), [layerId]);
const isBottom = useSyncExternalStore(subscribeOverlayStack, getIsBottom);
const depth = useSyncExternalStore(subscribeOverlayStack, getLayerDepth);
const { isOwner, keep, renderOwn } = resolveLayerLifetime(depth, isBottom, rendered);
// The layer's render output. The bottom (owner) layer fills the Modal; guest
// layers overlay it absolutely so they stack in document order.
const renderLayer = useCallback(
() => (
<>
{/* The dialog node: what the focus trap contains, and what carries the
role + label. `flex-1` / full-bleed absolute match what Modal children
already assume, so the wrapper is layout-neutral. */}
{children({ open, onExitComplete: handleExitComplete })}
{/* Overlay outlet: full-bleed layer above the panel, outside the dialog's
focus trap and accessibilityViewIsModal scope. touch-transparent on its
empty areas; injected content still receives its own touches. */}
>
),
[isOwner, renderOwn, accessibilityLabel, children, open, handleExitComplete],
);
// Register while the layer has a reason to be in the stack, and deregister once
// it has none. The bottom layer owns the single Modal; every later layer is a
// guest whose content the owner draws. `keep` is what holds a bottom layer in
// the stack after its own content has gone — see `resolveLayerLifetime`.
useLayoutEffect(() => {
if (!keep) {
if (layerIdRef.current !== null) {
removeOverlayLayer(layerIdRef.current);
layerIdRef.current = null;
setLayerId(null);
}
return;
}
if (layerIdRef.current === null) {
const id = upsertOverlayLayer({
render: renderLayer,
onExitComplete: stableExit,
onRequestClose: stableClose,
});
layerIdRef.current = id;
setLayerId(id);
}
}, [keep, renderLayer, stableExit, stableClose]);
// Deregister on unmount. If the host unmounts before the exit animation runs
// (a test file ending, or a parent being torn down mid-open), the layer would
// otherwise leak into the module-level stack and hold a slot nothing owns. The
// layers left above it are not stranded by this: ownership is read from the
// live stack, so the next layer down promotes itself and mounts the Modal.
useMountEffect(() => () => {
if (layerIdRef.current !== null) {
removeOverlayLayer(layerIdRef.current);
layerIdRef.current = null;
}
});
if (layerId === null) return null;
// The bottom layer keeps the Modal for as long as it is registered, including
// after its own content has exited, so the layers above it stay drawn.
if (isOwner) return ;
if (!rendered) return null;
return ;
}