import type { ReactNode } from 'react'; // ─── Layer stack state ───────────────────────────────────────────────────────── // One native Modal hosts every open overlay; layers draw bottom → top. let nextLayerId = 0; let layers: OverlayLayerState = []; const layerListeners = new Set<() => void>(); function notifyLayers() { for (const cb of layerListeners) cb(); } // ─── Outlet setter stack state ───────────────────────────────────────────────── // The toast portal re-targets onto the topmost layer's outlet. Outlets mount in // layer-render order inside the single Modal, so this stack (kept in mount order) // is the topmost layer's outlet by construction. /** One registered outlet — an (id, setter) pair so the stack survives concurrent updates. */ type OutletEntry = { id: number; setter: (node: ReactNode) => void }; let nextOutletId = 0; const outletStack: OutletEntry[] = []; const outletListeners = new Set<() => void>(); function notifyOutlets() { for (const cb of outletListeners) cb(); } // ─── Types ───────────────────────────────────────────────────────────────────── /** * One registered overlay layer — the unit the single root `` renders in * registration order (bottom → top). When N overlays open at once they collapse * into one native `` hosting N of these, so a confirm dialog raised from * a settings sheet can present on top of it on iOS. */ export type OverlayLayer = { id: number; /** The AnimatePresence-wrapped scrim + panel, plus this layer's outlet. */ render: () => ReactNode; /** * Fires once this layer's exit animation finishes. Stored on the layer so the * bottom (Modal-owning) layer can reason about a guest's exit state; each * `OverlayShell` drives its own deregistration off its `rendered` gate. */ onExitComplete: () => void; /** This layer's request-close handler (respects its `dismissable` flag). */ onRequestClose: () => void; }; /** Immutable layer stack, in registration order (bottom → top). */ export type OverlayLayerState = OverlayLayer[]; /** Input to {@link upsertOverlayLayer}. */ export type UpsertOverlayLayerInput = { id?: number; render: () => ReactNode; onExitComplete: () => void; onRequestClose: () => void; }; /** * How one `OverlayShell` participates in the shared layer stack right now, * resolved from the live stack by {@link resolveLayerLifetime}. */ export type LayerLifetime = { /** Whether this shell mounts the single `Modal` and draws every layer inside it. */ isOwner: boolean; /** Whether the layer stays registered in the stack. */ keep: boolean; /** Whether this shell draws its own content — false once it is only hosting. */ renderOwn: boolean; }; // ─── Pure layer-stack transitions (unit-testable without react-native) ──────── /** * Upsert a layer in place: append when `id` is new, replace the same slot when * it already exists — a layer never moves position just because its render * closure changed (the no-remount guarantee). */ export function upsertLayer(state: OverlayLayerState, id: number, layer: Omit): OverlayLayerState { const entry: OverlayLayer = { id, ...layer }; const index = state.findIndex((item) => item.id === id); if (index === -1) return [...state, entry]; const next = state.slice(); next[index] = entry; return next; } /** Remove a layer by id, preserving the relative order of the rest. */ export function removeLayer(state: OverlayLayerState, id: number): OverlayLayerState { if (!state.some((item) => item.id === id)) return state; return state.filter((item) => item.id !== id); } /** * Resolve one layer's registration lifetime from the live stack. * * The bottom layer owns the single native `Modal`, and every layer above it is * drawn *inside* that `Modal`. So the bottom layer has to outlive its own * content: an owner whose exit finishes while layers are still open above it * stays registered as a host that draws only those layers, which keeps the * `Modal` — and the layers in it — mounted. It leaves once the stack empties, * which is when the `Modal` unmounts. * * Ownership is answered from the stack on every render rather than latched when * the layer registers. That is what lets a bottom layer torn down outright (a * parent unmounting mid-open) promote the layer above it, instead of stranding * every open overlay with no owner to draw it. */ export function resolveLayerLifetime(depth: number, isBottom: boolean, rendered: boolean): LayerLifetime { return { isOwner: isBottom, keep: rendered || (isBottom && depth > 1), renderOwn: rendered, }; } // ─── Layer stack API ─────────────────────────────────────────────────────────── /** Subscribe to layer-stack changes (useSyncExternalStore). Returns an unsubscribe fn. */ export function subscribeOverlayStack(cb: () => void): () => void { layerListeners.add(cb); return () => { layerListeners.delete(cb); }; } /** The current layer stack, in registration order (bottom → top). */ export function getOverlayStack(): OverlayLayerState { return layers; } /** * Whether the layer with `id` is the bottom (Modal-owning) layer **right now**. * Answers `false` for a layer that is not registered (`null`). * * Both this and {@link getLayerDepth} return primitives so a shell can subscribe * with `useSyncExternalStore` and re-render only when its own answer changes — * subscribing to the stack array itself would re-render every open overlay on * every registration change. */ export function isBottomLayer(id: number | null): boolean { return id !== null && layers[0]?.id === id; } /** Number of registered layers. */ export function getLayerDepth(): number { return layers.length; } /** * Register a new layer, or refresh an existing one (matched by `input.id`) in * place. Returns the layer's id. * * Which layer owns the single `Modal` is deliberately **not** answered here. A * registration-time answer would be latched for the layer's whole life and go * stale the moment the stack changes underneath it — ask {@link isBottomLayer} * instead, which reads the live stack. */ export function upsertOverlayLayer(input: UpsertOverlayLayerInput): number { if (input.id === undefined) nextLayerId += 1; const id = input.id ?? nextLayerId; layers = upsertLayer(layers, id, { render: input.render, onExitComplete: input.onExitComplete, onRequestClose: input.onRequestClose, }); notifyLayers(); return id; } /** Remove a layer by id. No-op when it is not present. */ export function removeOverlayLayer(id: number): void { const next = removeLayer(layers, id); if (next === layers) return; layers = next; notifyLayers(); } // ─── Outlet setter stack API (toast portal) ──────────────────────────────────── /** * Subscribe to outlet-stack changes (used by useSyncExternalStore). * Returns an unsubscribe function. */ export function subscribeOutletStack(cb: () => void): () => void { outletListeners.add(cb); return () => { outletListeners.delete(cb); }; } /** * Register an outlet setter. Returns a cleanup function that unregisters it. * * Call inside `useMountEffect` so the outlet is registered exactly as long as * its View is mounted. The cleanup clears the outlet's content before popping * so a handed-off `OverlayPortal` doesn't leave a stale node in the closing * window. */ export function pushOutlet(setter: (node: ReactNode) => void): () => void { nextOutletId += 1; const id = nextOutletId; outletStack.push({ id, setter }); notifyOutlets(); return () => { const idx = outletStack.findIndex((e) => e.id === id); if (idx === -1) { notifyOutlets(); return; } // Clear the content before removing so the outlet doesn't hold a stale // node after it has been handed off to a different target. outletStack[idx]?.setter(null); outletStack.splice(idx, 1); notifyOutlets(); }; } /** * The setter for the topmost open overlay's outlet, or `null` when no overlay * is open. The returned function reference is stable for a given outlet, so * `useSyncExternalStore` only triggers a re-render when the stack actually * changes. */ export function getTopOutletSetter(): ((node: ReactNode) => void) | null { return outletStack.at(-1)?.setter ?? null; } /** Number of overlays with an outlet currently registered. */ export function getOutletDepth(): number { return outletStack.length; }