diff --git a/packages/src/context/provider/index.tsx b/packages/src/context/provider/index.tsx index 0e6e38f..0da91e8 100644 --- a/packages/src/context/provider/index.tsx +++ b/packages/src/context/provider/index.tsx @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useReducer, useRef, type PropsWithChildren } from 'react'; +import { useCallback, useEffect, useLayoutEffect, useReducer, useRef, type PropsWithChildren } from 'react'; import { ContentOverlayController } from './content-overlay-controller'; import { type OverlayEvent, createOverlay } from '../../event'; import { randomId } from '../../utils/random-id'; @@ -10,7 +10,24 @@ export function createOverlayProvider() { const { useOverlayEvent, ...overlay } = createOverlay(overlayId); const { OverlayContextProvider, useCurrentOverlay, useOverlayData } = createOverlaySafeContext(); + let instanceCounter = 0; + const mountedInstances: number[] = []; + function OverlayProvider({ children }: PropsWithChildren) { + const instanceIdRef = useRef(0); + + // Must run before useOverlayEvent so instanceIdRef is set when first event fires + useLayoutEffect(() => { + const id = ++instanceCounter; + instanceIdRef.current = id; + mountedInstances.push(id); + + return () => { + const idx = mountedInstances.indexOf(id); + if (idx !== -1) mountedInstances.splice(idx, 1); + }; + }, []); + const [overlayState, overlayDispatch] = useReducer(overlayReducer, { current: null, overlayOrderList: [], @@ -18,7 +35,12 @@ export function createOverlayProvider() { }); const prevOverlayState = useRef(overlayState); + const isLatestInstance = useCallback(() => { + return mountedInstances[mountedInstances.length - 1] === instanceIdRef.current; + }, []); + const open: OverlayEvent['open'] = useCallback(({ controller, overlayId, componentKey }) => { + if (!isLatestInstance()) return; overlayDispatch({ type: 'ADD', overlay: { @@ -29,7 +51,7 @@ export function createOverlayProvider() { controller: controller, }, }); - }, []); + }, [isLatestInstance]); const close: OverlayEvent['close'] = useCallback((overlayId: string) => { overlayDispatch({ type: 'CLOSE', overlayId }); }, []); diff --git a/packages/src/event.test.tsx b/packages/src/event.test.tsx index ac4e84d..601efad 100644 --- a/packages/src/event.test.tsx +++ b/packages/src/event.test.tsx @@ -1,6 +1,6 @@ import { act, render, screen, waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; -import React, { useEffect, type PropsWithChildren } from 'react'; +import React, { useEffect, useState, type PropsWithChildren } from 'react'; import { describe, expect, it, vi } from 'vitest'; import { OverlayProvider, overlay, useCurrentOverlay, useOverlayData } from './utils/create-overlay-context'; @@ -487,6 +487,58 @@ describe('overlay object', () => { }); }); + it('should only open overlay in the last mounted OverlayProvider when multiple are present', async () => { + const overlayContent = 'multiple-providers-overlay-content'; + + function Component() { + useEffect(() => { + overlay.open(({ isOpen }) => isOpen &&
{overlayContent}
); + }, []); + return
Component
; + } + + render( + + + + + + ); + + await waitFor(() => { + const overlayElements = screen.getAllByTestId('overlay-1'); + expect(overlayElements).toHaveLength(1); + }); + }); + + it('should fall back to previous OverlayProvider after the last one unmounts', async () => { + const overlayContent = 'fallback-provider-overlay-content'; + + function App() { + const [showSecond, setShowSecond] = useState(true); + + return ( + + + {showSecond && } + + ); + } + + const user = userEvent.setup(); + render(); + + await user.click(screen.getByRole('button', { name: 'remove' })); + + act(() => { + overlay.open(({ isOpen }) => isOpen &&
{overlayContent}
); + }); + + await waitFor(() => { + expect(screen.getByTestId('overlay-1')).toBeInTheDocument(); + }); + }); + it('should be able to open an overlay after closing it', async () => { const overlayId = 'overlay-content-1';