diff --git a/src/UniqueProvider/index.tsx b/src/UniqueProvider/index.tsx index 021f93b8..f7598fe3 100644 --- a/src/UniqueProvider/index.tsx +++ b/src/UniqueProvider/index.tsx @@ -60,10 +60,14 @@ const UniqueProvider = ({ const delayInvoke = useDelay(); + // Store the target of the latest show call, used to detect removed targets + const targetRef = React.useRef(null); + const show = useEvent( (showOptions: UniqueShowOptions, isOpen: () => boolean) => { // Store the isOpen function for later use in hide isOpenRef.current = isOpen; + targetRef.current = showOptions.target; delayInvoke(() => { trigger(showOptions); @@ -75,7 +79,9 @@ const UniqueProvider = ({ delayInvoke(() => { // Check if we should still hide by calling the isOpen function // If isOpen returns true, it means another trigger wants to keep it open - if (isOpenRef.current?.()) { + // Skip the check when the target is removed from the DOM (e.g. trigger unmounted), + // since its isOpen function is frozen and would keep returning true + if (isOpenRef.current?.() && targetRef.current?.isConnected) { return; // Don't hide if something else wants it open } @@ -138,10 +144,18 @@ const UniqueProvider = ({ mergedOptions?.prefixCls, ]); + const release = useEvent((target: HTMLElement) => { + // Close the popup only when it is currently showing for the removed target + if (open && targetRef.current === target) { + trigger(false); + } + }); + const contextValue = React.useMemo( () => ({ show, hide, + release, }), [], ); diff --git a/src/context.ts b/src/context.ts index 728c844f..5f90f607 100644 --- a/src/context.ts +++ b/src/context.ts @@ -41,6 +41,8 @@ export interface UniqueShowOptions { export interface UniqueContextProps { show: (options: UniqueShowOptions, isOpen: () => boolean) => void; hide: (delay: number) => void; + /** Notify the provider a trigger target is removed (e.g. trigger unmounted) */ + release: (target: HTMLElement) => void; } export const UniqueContext = React.createContext( diff --git a/src/index.tsx b/src/index.tsx index 22f29a0a..6840bde6 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -265,6 +265,10 @@ export function generateTrigger( // Use state to control here since `useRef` update not trigger render const [targetEle, setTargetEle] = React.useState(null); + // Keep the latest target for unmount checking + const targetEleRef = React.useRef(null); + targetEleRef.current = targetEle; + // Used for forwardRef target. Not use internal const externalForwardRef = React.useRef(null); @@ -386,6 +390,17 @@ export function generateTrigger( const openRef = React.useRef(mergedOpen); openRef.current = mergedOpen; + // Close the shared popup when the trigger is unmounted + React.useEffect(() => { + if (uniqueContext && unique && !parentContext) { + return () => { + if (targetEleRef.current) { + uniqueContext.release(targetEleRef.current); + } + }; + } + }, []); + const internalTriggerOpen = useEvent((nextOpen: boolean) => { flushSync(() => { if (rawOpen !== nextOpen) { diff --git a/tests/unique.test.tsx b/tests/unique.test.tsx index 2d69e57b..acd204fe 100644 --- a/tests/unique.test.tsx +++ b/tests/unique.test.tsx @@ -376,7 +376,7 @@ describe('Trigger.Unique', () => { }); it('esc should close unique popup', async () => { - const { container,baseElement } = render( + const { container, baseElement } = render( Popup} unique>
@@ -389,4 +389,85 @@ describe('Trigger.Unique', () => { fireEvent.keyDown(window, { key: 'Escape' }); expect(baseElement.querySelector('.rc-trigger-popup-hidden')).toBeTruthy(); }); + + it('should hide popup when the trigger is unmounted', async () => { + const Demo = ({ show }: { show: boolean }) => ( + + {show && ( + tooltip} + unique + mouseEnterDelay={0.1} + mouseLeaveDelay={0.1} + > +
hover me
+
+ )} +
+ ); + + const { container, rerender } = render(); + + // Hover to open the popup + fireEvent.mouseEnter(container.querySelector('.target')); + await awaitFakeTimer(); + + expect(document.querySelector('.rc-trigger-popup')).toBeTruthy(); + expect(document.querySelector('.rc-trigger-popup').className).not.toContain( + '-hidden', + ); + + // Unmount the trigger while the popup is open + rerender(); + await awaitFakeTimer(); + + // Popup should be hidden + expect(document.querySelector('.rc-trigger-popup').className).toContain( + '-hidden', + ); + }); + + it('should not hide popup when an inactive trigger is unmounted', async () => { + const Demo = ({ showSecond }: { showSecond: boolean }) => ( + + tooltip1} + unique + mouseEnterDelay={0.1} + mouseLeaveDelay={0.1} + > +
hover1
+
+ {showSecond && ( + tooltip2} + unique + > +
hover2
+
+ )} +
+ ); + + const { container, rerender } = render(); + + // Hover first trigger to open the popup + fireEvent.mouseEnter(container.querySelector('.target1')); + await awaitFakeTimer(); + expect(document.querySelector('.x-content').textContent).toBe('tooltip1'); + expect(document.querySelector('.rc-trigger-popup').className).not.toContain( + '-hidden', + ); + + // Unmount the second (inactive) trigger, popup should keep open + rerender(); + await awaitFakeTimer(); + + expect(document.querySelector('.rc-trigger-popup').className).not.toContain( + '-hidden', + ); + }); });