1import { type ReactNode, useEffect, useState, useSyncExternalStore } from "react";
2import { View } from "react-native";
3
4// RN Modals move native window focus and close the keyboard. Keep composer menus in-window.
5type Entries = ReadonlyMap<number, ReactNode>;
6
7let nextKey = 0;
8
9function allocateKey() {
10 return nextKey++;
11}
12const entries = new Map<number, ReactNode>();
13const listeners = new Set<() => void>();
14let snapshot: Entries = new Map();
15
16function emit() {
17 snapshot = new Map(entries);
18 for (const listener of listeners) {
19 listener();
20 }
21}
22
23function subscribe(listener: () => void) {
24 listeners.add(listener);
25 return () => {
26 listeners.delete(listener);
27 };
28}
29
30function getSnapshot() {
31 return snapshot;
32}
33
34export function OverlayPortal(props: { readonly children: ReactNode }) {
35 const [key] = useState(allocateKey);
36
37 // RN has no createPortal, so projecting into the out-of-tree host has to
38 // happen after commit. No dependency array: re-project after every render so
39 // the host always shows the current content (menus re-render while open —
40 // drill-in, theme).
41 // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- projects children into the out-of-tree overlay host store after every commit
42 useEffect(() => {
43 entries.set(key, props.children);
44 emit();
45 });
46
47 // Removal is unmount-only: deleting on every re-projection would move the
48 // entry to the end of the map and restack it above overlays opened later.
49 // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- removes this entry from the overlay host store on unmount
50 useEffect(
51 () => () => {
52 entries.delete(key);
53 emit();
54 },
55 [key],
56 );
57
58 return null;
59}
60
61export function OverlayPortalHost() {
62 const current = useSyncExternalStore(subscribe, getSnapshot, getSnapshot);
63
64 if (current.size === 0) {
65 return null;
66 }
67 return (
68 <View pointerEvents="box-none" className="absolute inset-0">
69 {[...current.entries()].map(([key, node]) => (
70 <View key={key} pointerEvents="box-none" className="absolute inset-0">
71 {node}
72 </View>
73 ))}
74 </View>
75 );
76}