From 94f35eb433cf9ede2431b382a7797edb3b23527e Mon Sep 17 00:00:00 2001 From: manNomi Date: Fri, 27 Feb 2026 19:45:46 +0900 Subject: [PATCH] feat: only the last mounted OverlayProvider handles open events When multiple OverlayProvider instances share the same overlay context, all providers previously received open events causing duplicate renders. Each provider now tracks its mount order and only the most recently mounted instance handles open events, falling back to the previous provider when it unmounts. Co-Authored-By: Claude Sonnet 4.6 --- packages/src/context/provider/index.tsx | 26 +++++++++++- packages/src/event.test.tsx | 54 ++++++++++++++++++++++++- 2 files changed, 77 insertions(+), 3 deletions(-) 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';