From c771acae2f96bf02a44c8e9cb92106ab299e3302 Mon Sep 17 00:00:00 2001 From: Ivan Sekovanikj Date: Fri, 16 Oct 2026 12:15:09 +0200 Subject: [PATCH] fix: differentiate host layers by id and self register --- .../UIComponents/PortalWhileClosingView.tsx | 19 +++- .../__tests__/PortalWhileClosingView.test.tsx | 38 ++++++++ .../ClosingPortalHostsLayer.tsx | 20 +++- .../MessageOverlayHostLayer.tsx | 63 +++++++++--- .../overlayContext/OverlayHostLayerContext.ts | 9 ++ .../overlayContext/OverlayProvider.tsx | 19 ++-- .../ClosingPortalHostsLayer.test.tsx | 27 ++++++ .../MessageOverlayHostLayer.test.tsx | 90 +++++++++++++++-- .../__tests__/message-overlay-store.test.tsx | 97 +++++++++++++++++++ .../src/state-store/message-overlay-store.ts | 81 +++++++++++++--- 10 files changed, 417 insertions(+), 46 deletions(-) create mode 100644 package/src/contexts/overlayContext/OverlayHostLayerContext.ts diff --git a/package/src/components/UIComponents/PortalWhileClosingView.tsx b/package/src/components/UIComponents/PortalWhileClosingView.tsx index 50f97bee8d..0da026fb72 100644 --- a/package/src/components/UIComponents/PortalWhileClosingView.tsx +++ b/package/src/components/UIComponents/PortalWhileClosingView.tsx @@ -6,6 +6,7 @@ import { useSafeAreaInsets } from 'react-native-safe-area-context'; import { Portal } from 'react-native-teleport'; import { measureInWindow } from '../../components/Message/utils/measureInWindow'; +import { useOverlayHostLayerId } from '../../contexts/overlayContext/OverlayHostLayerContext'; import { useStableCallback } from '../../hooks'; import { clearClosingPortalLayout, @@ -14,6 +15,7 @@ import { useShouldTeleportToClosingPortal, useHasActiveId, useIsOverlayClosing, + useIsTopOverlayHostLayer, } from '../../state-store'; import type { ViewRef } from '../../types/react-native-compat'; @@ -79,11 +81,16 @@ export const PortalWhileClosingView = ({ const registrationId = registrationIdRef.current; + const overlayHostLayerId = useOverlayHostLayerId(); + const isTopOverlayHostLayer = useIsTopOverlayHostLayer(overlayHostLayerId); + const { syncPortalLayout, containerRef, placeholderLayout } = useSyncingApi( portalHostName, registrationId, + isTopOverlayHostLayer, ); - const shouldTeleport = useShouldTeleportToClosingPortal(portalHostName, registrationId); + const shouldTeleport = + useShouldTeleportToClosingPortal(portalHostName, registrationId) && isTopOverlayHostLayer; useEffect(() => { return () => { @@ -114,7 +121,11 @@ export const PortalWhileClosingView = ({ ); }; -const useSyncingApi = (portalHostName: string, registrationId: string) => { +const useSyncingApi = ( + portalHostName: string, + registrationId: string, + isTopOverlayHostLayer: boolean, +) => { const containerRef = useRef(null); const placeholderLayout = useSharedValue({ h: 0, w: 0 }); const insets = useSafeAreaInsets(); @@ -122,7 +133,7 @@ const useSyncingApi = (portalHostName: string, registrationId: string) => { const isClosing = useIsOverlayClosing(); const syncPortalLayout = useStableCallback(() => { - if (!hasActiveId && !isClosing) { + if ((!hasActiveId && !isClosing) || !isTopOverlayHostLayer) { return; } @@ -140,7 +151,7 @@ const useSyncingApi = (portalHostName: string, registrationId: string) => { if (hasActiveId || isClosing) { syncPortalLayout(); } - }, [insets.bottom, isClosing, hasActiveId, syncPortalLayout]); + }, [insets.bottom, isClosing, hasActiveId, isTopOverlayHostLayer, syncPortalLayout]); return useMemo( () => ({ syncPortalLayout, containerRef, placeholderLayout }), diff --git a/package/src/components/UIComponents/__tests__/PortalWhileClosingView.test.tsx b/package/src/components/UIComponents/__tests__/PortalWhileClosingView.test.tsx index 20254843f9..0dd35beda9 100644 --- a/package/src/components/UIComponents/__tests__/PortalWhileClosingView.test.tsx +++ b/package/src/components/UIComponents/__tests__/PortalWhileClosingView.test.tsx @@ -3,6 +3,7 @@ import { Text } from 'react-native'; import { act, cleanup, render, screen } from '@testing-library/react-native'; +import { OverlayHostLayerContext } from '../../../contexts/overlayContext/OverlayHostLayerContext'; import * as stateStore from '../../../state-store'; import { PortalWhileClosingView } from '../PortalWhileClosingView'; @@ -172,4 +173,41 @@ describe('PortalWhileClosingView', () => { expect(screen.getByTestId('teleport-state')).toHaveTextContent('false'); }); + + it('stays in place while its overlay provider is not the top one', () => { + let unregisterRootLayer = () => {}; + let unregisterNestedLayer = () => {}; + + act(() => { + unregisterRootLayer = stateStore.registerOverlayHostLayer('root-layer'); + unregisterNestedLayer = stateStore.registerOverlayHostLayer('nested-layer'); + }); + + render( + + + Composer + + , + ); + + act(() => { + stateStore.setClosingPortalLayout('overlay-composer', 'registration-1', BASE_RECT); + stateStore.openOverlay('message-1'); + stateStore.closeOverlay(); + }); + flushAnimationFrameQueue(); + + expect(screen.queryByTestId('portal-while-closing-placeholder-composer-portal')).toBeNull(); + + act(() => { + unregisterNestedLayer(); + }); + + expect(screen.getByTestId('portal-while-closing-placeholder-composer-portal')).toBeTruthy(); + + act(() => { + unregisterRootLayer(); + }); + }); }); diff --git a/package/src/contexts/overlayContext/ClosingPortalHostsLayer.tsx b/package/src/contexts/overlayContext/ClosingPortalHostsLayer.tsx index d3068ac546..3119a96d6a 100644 --- a/package/src/contexts/overlayContext/ClosingPortalHostsLayer.tsx +++ b/package/src/contexts/overlayContext/ClosingPortalHostsLayer.tsx @@ -9,12 +9,14 @@ type ClosingPortalHostSlotProps = { closeCoverOpacity: SharedValue; hostName: string; layout: ClosingPortalLayoutEntry['layout']; + portalHostName: string; }; const ClosingPortalHostSlot = ({ closeCoverOpacity, hostName, layout, + portalHostName, }: ClosingPortalHostSlotProps) => { const style = useAnimatedStyle(() => { const value = layout.value; @@ -36,16 +38,29 @@ const ClosingPortalHostSlot = ({ style={style} testID={`closing-portal-host-slot-${hostName}`} > - + ); }; type ClosingPortalHostsLayerProps = { closeCoverOpacity: SharedValue; + /** + * Id of the owning host layer, used to park the slot hosts while that layer is not the top one. + */ + hostLayerId?: string; + /** + * Whether the owning host layer is the top one. Slots of any other layer park their hosts under + * names unique to that layer, so they never take the shared names from the top layer. + */ + isTopHostLayer?: boolean; }; -export const ClosingPortalHostsLayer = ({ closeCoverOpacity }: ClosingPortalHostsLayerProps) => { +export const ClosingPortalHostsLayer = ({ + closeCoverOpacity, + hostLayerId, + isTopHostLayer = true, +}: ClosingPortalHostsLayerProps) => { const closingPortalLayoutStacks = useClosingPortalLayouts(); const closingPortalHosts = useMemo(() => { const topHosts: Array<{ @@ -71,6 +86,7 @@ export const ClosingPortalHostsLayer = ({ closeCoverOpacity }: ClosingPortalHost hostName={hostName} key={hostName} layout={layout} + portalHostName={isTopHostLayer ? hostName : `${hostName}:${hostLayerId}`} /> ))} diff --git a/package/src/contexts/overlayContext/MessageOverlayHostLayer.tsx b/package/src/contexts/overlayContext/MessageOverlayHostLayer.tsx index d5b1a2029e..c1d436eb40 100644 --- a/package/src/contexts/overlayContext/MessageOverlayHostLayer.tsx +++ b/package/src/contexts/overlayContext/MessageOverlayHostLayer.tsx @@ -1,4 +1,4 @@ -import React, { useEffect, useMemo, useRef } from 'react'; +import React, { useEffect, useLayoutEffect, useMemo, useRef } from 'react'; import { BackHandler, I18nManager, @@ -33,8 +33,10 @@ import { useScreenOrientation } from '../../hooks'; import { closeOverlay, finalizeCloseOverlay, + registerOverlayHostLayer, registerOverlaySharedValueController, Rect, + useIsTopOverlayHostLayer, useOverlayController, } from '../../state-store'; import { useComponentsContext } from '../componentsContext/ComponentsContext'; @@ -69,9 +71,21 @@ export type MessageActionsProps = { topItemStyle: StyleProp>; }; -export const MessageOverlayHostLayer = () => { +type MessageOverlayHostLayerProps = { + /** + * Id under which this layer registers on the host layer stack. `OverlayProvider` passes its own + * so that views inside the provider can tell whether this layer is the top one. + */ + hostLayerId: string; +}; + +export const MessageOverlayHostLayer = ({ hostLayerId }: MessageOverlayHostLayerProps) => { const { MessageActions, MessageOverlayBackground } = useComponentsContext(); const { id, closing } = useOverlayController(); + + useLayoutEffect(() => registerOverlayHostLayer(hostLayerId), [hostLayerId]); + + const isTop = useIsTopOverlayHostLayer(hostLayerId); const insets = useSafeAreaInsets(); const { height: screenH } = useWindowDimensions(); const messageH = useSharedValue(undefined); @@ -86,7 +100,7 @@ export const MessageOverlayHostLayer = () => { // if the navigation bar is actually 0 - we end up animating a little bit further. const bottomInset = insets.bottom === 0 && Platform.OS === 'android' ? 60 : insets.bottom; - const isActive = !!id; + const isActive = !!id && isTop; const announce = useAccessibilityAnnouncer(); const overlayOpenHint = useA11yLabel('a11y/Swipe right to go through different actions'); @@ -127,8 +141,10 @@ export const MessageOverlayHostLayer = () => { return; } prevOrientationRef.current = orientation; - finalizeCloseOverlay(); - }, [orientation]); + if (isTop) { + finalizeCloseOverlay(); + } + }, [isTop, orientation]); const padding = 8; const minY = topInset + padding; @@ -201,6 +217,9 @@ export const MessageOverlayHostLayer = () => { ); useEffect(() => { + if (!isTop) { + return; + } const target = isActive && !closing ? 1 : 0; backdrop.value = withSpring(target, { duration: DURATION + target * 100 }, (finished) => { if (finished && closing) { @@ -208,7 +227,7 @@ export const MessageOverlayHostLayer = () => { } }); closeCoverOpacity.value = withSpring(closing ? 1 : 0, { duration: DURATION }); - }, [isActive, closing, backdrop, closeCoverOpacity]); + }, [isActive, isTop, closing, backdrop, closeCoverOpacity]); const backdropStyle = useAnimatedStyle(() => ({ opacity: backdrop.value, @@ -375,6 +394,10 @@ export const MessageOverlayHostLayer = () => { [bottomH, bottomShiftY, messageShiftY, pan, topH], ); + const messageHostName = isTop ? 'message-overlay' : `message-overlay:${hostLayerId}`; + const topItemHostName = isTop ? 'top-item' : `top-item:${hostLayerId}`; + const bottomItemHostName = isTop ? 'bottom-item' : `bottom-item:${hostLayerId}`; + return ( { ) : null} {MessageActions ? ( - + // A custom `MessageActions` renders its own hosts with the shared names, so only the + // top layer renders it. + isTop ? ( + + ) : null ) : ( <> {/* @@ -412,22 +439,26 @@ export const MessageOverlayHostLayer = () => { */} - + - + - + )} - + ); diff --git a/package/src/contexts/overlayContext/OverlayHostLayerContext.ts b/package/src/contexts/overlayContext/OverlayHostLayerContext.ts new file mode 100644 index 0000000000..a40fb81565 --- /dev/null +++ b/package/src/contexts/overlayContext/OverlayHostLayerContext.ts @@ -0,0 +1,9 @@ +import React, { useContext } from 'react'; + +/** + * The id of the `MessageOverlayHostLayer` rendered by the nearest `OverlayProvider`. Views that + * teleport into that layer use it to tell whether their provider currently owns the overlay. + */ +export const OverlayHostLayerContext = React.createContext(undefined); + +export const useOverlayHostLayerId = () => useContext(OverlayHostLayerContext); diff --git a/package/src/contexts/overlayContext/OverlayProvider.tsx b/package/src/contexts/overlayContext/OverlayProvider.tsx index 030a40a3f8..1c53be687c 100644 --- a/package/src/contexts/overlayContext/OverlayProvider.tsx +++ b/package/src/contexts/overlayContext/OverlayProvider.tsx @@ -8,10 +8,12 @@ import { PortalProvider } from 'react-native-teleport'; import { MessageOverlayHostLayer } from './MessageOverlayHostLayer'; import { OverlayContext, OverlayProviderProps } from './OverlayContext'; +import { OverlayHostLayerContext } from './OverlayHostLayerContext'; import { OverlayA11yShield } from '../../components/Accessibility/OverlayA11yShield'; import { ImageGallery } from '../../components/ImageGallery/ImageGallery'; import { useStreami18n } from '../../hooks/useStreami18n'; +import { createOverlayHostLayerId } from '../../state-store'; import { AccessibilityProvider } from '../accessibilityContext/AccessibilityContext'; import { ImageGalleryProvider } from '../imageGalleryContext/ImageGalleryContext'; @@ -55,6 +57,7 @@ export const OverlayProvider = (props: PropsWithChildren) } = props; const [overlay, setOverlay] = useState(value?.overlay || 'none'); + const [overlayHostLayerId] = useState(createOverlayHostLayerId); const overlayOpacity = useSharedValue(0); @@ -110,13 +113,15 @@ export const OverlayProvider = (props: PropsWithChildren) - {accessibility?.enabled ? ( - {children} - ) : ( - children - )} - {overlay === 'gallery' && } - + + {accessibility?.enabled ? ( + {children} + ) : ( + children + )} + {overlay === 'gallery' && } + + diff --git a/package/src/contexts/overlayContext/__tests__/ClosingPortalHostsLayer.test.tsx b/package/src/contexts/overlayContext/__tests__/ClosingPortalHostsLayer.test.tsx index faa4874f0a..b8052eb8bb 100644 --- a/package/src/contexts/overlayContext/__tests__/ClosingPortalHostsLayer.test.tsx +++ b/package/src/contexts/overlayContext/__tests__/ClosingPortalHostsLayer.test.tsx @@ -115,4 +115,31 @@ describe('ClosingPortalHostsLayer', () => { expect(screen.getAllByTestId('closing-portal-host-slot-overlay-header')).toHaveLength(1); expect(screen.getAllByTestId('closing-portal-host-slot-overlay-composer')).toHaveLength(1); }); + + it('parks its hosts under names unique to its host layer while that layer is not the top one', () => { + const renderLayer = (isTopHostLayer: boolean) => ( + } + hostLayerId='root-layer' + isTopHostLayer={isTopHostLayer} + /> + ); + const getHostsNamed = (name: string) => + screen.UNSAFE_root.findAll( + (node) => typeof node.type === 'string' && node.props.name === name, + ); + const { rerender } = render(renderLayer(false)); + + act(() => { + setClosingPortalLayout('overlay-composer', 'composer-entry', FIRST_RECT); + }); + + expect(getHostsNamed('overlay-composer')).toHaveLength(0); + expect(getHostsNamed('overlay-composer:root-layer')).toHaveLength(1); + + rerender(renderLayer(true)); + + expect(getHostsNamed('overlay-composer')).toHaveLength(1); + expect(getHostsNamed('overlay-composer:root-layer')).toHaveLength(0); + }); }); diff --git a/package/src/contexts/overlayContext/__tests__/MessageOverlayHostLayer.test.tsx b/package/src/contexts/overlayContext/__tests__/MessageOverlayHostLayer.test.tsx index 299b17f8a9..ede0437630 100644 --- a/package/src/contexts/overlayContext/__tests__/MessageOverlayHostLayer.test.tsx +++ b/package/src/contexts/overlayContext/__tests__/MessageOverlayHostLayer.test.tsx @@ -159,7 +159,7 @@ describe('MessageOverlayHostLayer', () => { const CustomBackground = () => Background; render( - + , ); @@ -197,7 +197,7 @@ describe('MessageOverlayHostLayer', () => { render( - + , ); @@ -239,7 +239,7 @@ describe('MessageOverlayHostLayer', () => { it('positions and translates the top, message, and bottom hosts using the registered rects', () => { const renderTree = () => ( - + ); const { rerender } = render(renderTree()); @@ -289,7 +289,7 @@ describe('MessageOverlayHostLayer', () => { it('keeps the hosts mounted but resets their geometry after finalizeCloseOverlay', () => { const renderTree = () => ( - + ); const { rerender } = render(renderTree()); @@ -341,7 +341,7 @@ describe('MessageOverlayHostLayer', () => { const renderTree = () => ( - + ); const { rerender } = render(renderTree()); @@ -396,7 +396,7 @@ describe('MessageOverlayHostLayer', () => { MessageOverlayBackground: NoopBackground, }} > - + ); const { rerender } = render(renderTree()); @@ -433,4 +433,82 @@ describe('MessageOverlayHostLayer', () => { width: BOTTOM_RECT.w, }); }); + + it('lets the most recently mounted host layer own the overlay and hands it back once that layer unmounts', () => { + const renderTree = (withNestedLayer: boolean) => ( + + + {withNestedLayer ? : null} + + ); + const getHostsNamed = (name: string) => + screen.UNSAFE_root.findAll( + (node) => typeof node.type === 'string' && node.props.name === name, + ); + const { rerender } = render(renderTree(true)); + + // The nested layer owns the shared host names, the root layer parks its own. + expect(getHostsNamed('bottom-item')).toHaveLength(1); + expect(getHostsNamed('bottom-item:root-layer')).toHaveLength(1); + + act(() => { + setOverlayTopH(TOP_RECT); + setOverlayMessageH(MESSAGE_RECT); + setOverlayBottomH(BOTTOM_RECT); + openOverlay('message-1'); + }); + + rerender(renderTree(true)); + + expect(screen.getAllByTestId('message-overlay-backdrop')).toHaveLength(1); + const [rootBottomSlot, nestedBottomSlot] = screen.getAllByTestId('message-overlay-bottom'); + expect(StyleSheet.flatten(nestedBottomSlot.props.style)).toMatchObject({ + height: BOTTOM_RECT.h, + width: BOTTOM_RECT.w, + }); + expect(StyleSheet.flatten(rootBottomSlot.props.style)).toMatchObject({ height: 0 }); + + act(() => { + finalizeCloseOverlay(); + }); + + rerender(renderTree(false)); + + expect(getHostsNamed('bottom-item')).toHaveLength(1); + expect(getHostsNamed('bottom-item:root-layer')).toHaveLength(0); + + act(() => { + setOverlayTopH(TOP_RECT); + setOverlayMessageH(MESSAGE_RECT); + setOverlayBottomH(BOTTOM_RECT); + openOverlay('message-2'); + }); + + rerender(renderTree(false)); + + expect(screen.getAllByTestId('message-overlay-backdrop')).toHaveLength(1); + expect( + StyleSheet.flatten(screen.getByTestId('message-overlay-bottom').props.style), + ).toMatchObject({ + height: BOTTOM_RECT.h, + width: BOTTOM_RECT.w, + }); + }); + + it('renders a MessageActions override only in the top host layer', () => { + const renderTree = () => ( + + + + + ); + render(renderTree()); + + expect(screen.getAllByTestId('custom-message-actions')).toHaveLength(1); + }); }); diff --git a/package/src/state-store/__tests__/message-overlay-store.test.tsx b/package/src/state-store/__tests__/message-overlay-store.test.tsx index cdd8c903eb..773acbb4b3 100644 --- a/package/src/state-store/__tests__/message-overlay-store.test.tsx +++ b/package/src/state-store/__tests__/message-overlay-store.test.tsx @@ -9,10 +9,15 @@ import { finalizeCloseOverlay, openOverlay, overlayStore, + registerOverlayHostLayer, + registerOverlaySharedValueController, scheduleActionOnClose, setClosingPortalLayout, + setOverlayBottomH, + setOverlayMessageH, useClosingPortalHostBlacklist, useClosingPortalHostBlacklistState, + useIsTopOverlayHostLayer, useShouldTeleportToClosingPortal, } from '../message-overlay-store'; @@ -308,3 +313,95 @@ describe('message overlay store portal hooks', () => { allowed.unmount(); }); }); + +describe('message overlay host layer stack', () => { + const createController = () => ({ + incrementCloseCorrectionY: jest.fn(), + reset: jest.fn(), + resetCloseCorrectionY: jest.fn(), + setBottomH: jest.fn(), + setMessageH: jest.fn(), + setTopH: jest.fn(), + }); + + afterEach(() => { + cleanup(); + }); + + it('routes the overlay geometry to the most recently registered controller and hands it back once that one unregisters', () => { + const rootController = createController(); + const nestedController = createController(); + const unregisterRoot = registerOverlaySharedValueController(rootController); + const unregisterNested = registerOverlaySharedValueController(nestedController); + + setOverlayMessageH(BASE_RECT); + + expect(nestedController.setMessageH).toHaveBeenCalledWith(BASE_RECT); + expect(rootController.setMessageH).not.toHaveBeenCalled(); + + unregisterNested(); + setOverlayMessageH(BASE_RECT); + + expect(rootController.setMessageH).toHaveBeenCalledWith(BASE_RECT); + expect(nestedController.setMessageH).toHaveBeenCalledTimes(1); + + unregisterRoot(); + }); + + it('keeps routing to the top controller when a controller below it unregisters', () => { + const rootController = createController(); + const nestedController = createController(); + const unregisterRoot = registerOverlaySharedValueController(rootController); + const unregisterNested = registerOverlaySharedValueController(nestedController); + + unregisterRoot(); + setOverlayBottomH(BASE_RECT); + + expect(nestedController.setBottomH).toHaveBeenCalledWith(BASE_RECT); + expect(rootController.setBottomH).not.toHaveBeenCalled(); + + unregisterNested(); + }); + + it('reports only the most recently registered host layer as the top one', () => { + const root = renderHook(() => useIsTopOverlayHostLayer('root-layer')); + const nested = renderHook(() => useIsTopOverlayHostLayer('nested-layer')); + const withoutLayer = renderHook(() => useIsTopOverlayHostLayer(undefined)); + + expect(root.result.current).toBe(true); + expect(nested.result.current).toBe(true); + + let unregisterRoot = () => {}; + let unregisterNested = () => {}; + + act(() => { + unregisterRoot = registerOverlayHostLayer('root-layer'); + }); + + expect(root.result.current).toBe(true); + expect(nested.result.current).toBe(false); + + act(() => { + unregisterNested = registerOverlayHostLayer('nested-layer'); + }); + + expect(root.result.current).toBe(false); + expect(nested.result.current).toBe(true); + expect(withoutLayer.result.current).toBe(true); + + act(() => { + unregisterNested(); + }); + + expect(root.result.current).toBe(true); + expect(nested.result.current).toBe(false); + + act(() => { + unregisterRoot(); + }); + + root.unmount(); + nested.unmount(); + withoutLayer.unmount(); + }); +}); diff --git a/package/src/state-store/message-overlay-store.ts b/package/src/state-store/message-overlay-store.ts index 4334fbb421..85684a04fb 100644 --- a/package/src/state-store/message-overlay-store.ts +++ b/package/src/state-store/message-overlay-store.ts @@ -51,37 +51,43 @@ type OverlaySharedValueController = { setTopH: (rect: Rect) => void; }; -let sharedValueController: OverlaySharedValueController | undefined; +// Every mounted `MessageOverlayHostLayer` registers its controller and the most recently registered +// one drives the overlay. Unregistering hands control back to the previous controller, so nested +// `OverlayProvider`s (one at the root and one inside a native modal, for example) can come and go. +let sharedValueControllerStack: OverlaySharedValueController[] = []; + +const getSharedValueController = () => + sharedValueControllerStack[sharedValueControllerStack.length - 1]; export const registerOverlaySharedValueController = (controller: OverlaySharedValueController) => { - sharedValueController = controller; + sharedValueControllerStack = [...sharedValueControllerStack, controller]; return () => { - if (sharedValueController === controller) { - sharedValueController = undefined; - } + sharedValueControllerStack = sharedValueControllerStack.filter( + (registeredController) => registeredController !== controller, + ); }; }; export const setOverlayMessageH = (messageH: Rect) => { - sharedValueController?.setMessageH(messageH); + getSharedValueController()?.setMessageH(messageH); }; export const setOverlayTopH = (topH: Rect) => { - sharedValueController?.setTopH(topH); + getSharedValueController()?.setTopH(topH); }; export const setOverlayBottomH = (bottomH: Rect) => { - sharedValueController?.setBottomH(bottomH); + getSharedValueController()?.setBottomH(bottomH); }; export const bumpOverlayLayoutRevision = (closeCorrectionDeltaY = 0) => { - sharedValueController?.incrementCloseCorrectionY(closeCorrectionDeltaY); + getSharedValueController()?.incrementCloseCorrectionY(closeCorrectionDeltaY); }; export const openOverlay = (params: OpenOverlayParams | string) => { const overlayPayload = typeof params === 'string' ? { id: params, messageId: undefined } : params; - sharedValueController?.resetCloseCorrectionY(); + getSharedValueController()?.resetCloseCorrectionY(); overlayStore.partialNext({ closing: false, closingPortalHostBlacklist: getCurrentClosingPortalHostBlacklist(), @@ -122,7 +128,7 @@ export const finalizeCloseOverlay = () => { ...DefaultState, closingPortalHostBlacklist: getCurrentClosingPortalHostBlacklist(), }); - sharedValueController?.reset(); + getSharedValueController()?.reset(); }; export const overlayStore = new StateStore(DefaultState); @@ -133,6 +139,59 @@ const closingPortalLayoutsStore = new StateStore( export const createClosingPortalLayoutRegistrationId = () => `closing-portal-layout-${closingPortalLayoutRegistrationCounter++}`; +type OverlayHostLayersState = { + hostLayerIds: string[]; +}; + +let overlayHostLayerRegistrationCounter = 0; + +const overlayHostLayersStore = new StateStore({ hostLayerIds: [] }); + +export const createOverlayHostLayerId = () => + `overlay-host-layer-${overlayHostLayerRegistrationCounter++}`; + +/** + * Registers a `MessageOverlayHostLayer` (one per `OverlayProvider`) using stack semantics. + * + * Only the most recently registered layer drives the overlay and owns the shared portal host names. + * Portal hosts are looked up by name in a single native registry, so two layers using the same + * names would take them over from each other. The other layers park their hosts under names unique + * to them, and when the top layer unregisters the previous one takes the shared names back. + */ +export const registerOverlayHostLayer = (id: string) => { + overlayHostLayersStore.partialNext({ + hostLayerIds: [...overlayHostLayersStore.getLatestValue().hostLayerIds, id], + }); + + return () => { + overlayHostLayersStore.partialNext({ + hostLayerIds: overlayHostLayersStore + .getLatestValue() + .hostLayerIds.filter((hostLayerId) => hostLayerId !== id), + }); + }; +}; + +const topOverlayHostLayer = { isTop: true }; +const notTopOverlayHostLayer = { isTop: false }; + +/** + * Whether the host layer with the given id is the one that currently owns the overlay. A missing + * id (used outside of an `OverlayProvider`) or an empty stack counts as the top, which keeps the + * single provider setup identical to before the stack existed. + */ +export const useIsTopOverlayHostLayer = (id: string | undefined) => { + const isTopOverlayHostLayerSelector = useCallback( + ({ hostLayerIds }: OverlayHostLayersState) => + !id || !hostLayerIds.length || hostLayerIds[hostLayerIds.length - 1] === id + ? topOverlayHostLayer + : notTopOverlayHostLayer, + [id], + ); + + return useStateStore(overlayHostLayersStore, isTopOverlayHostLayerSelector).isTop; +}; + const getCurrentClosingPortalHostBlacklist = () => closingPortalHostBlacklistStack[closingPortalHostBlacklistStack.length - 1]?.hostNames ?? [];