From f43f0b8ca4d7330b582055b2c540ef53fc1ae026 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BA=8C=E8=B4=A7=E6=9C=BA=E5=99=A8=E4=BA=BA?= Date: Tue, 29 Sep 2026 11:09:37 +0800 Subject: [PATCH 1/2] fix: reset stack hover when hovered notifications are destroyed --- src/Notification.tsx | 8 ++++++ src/NotificationList/index.tsx | 8 +++++- tests/stack-destroy.test.tsx | 50 ++++++++++++++++++++++++++++++++++ 3 files changed, 65 insertions(+), 1 deletion(-) create mode 100644 tests/stack-destroy.test.tsx diff --git a/src/Notification.tsx b/src/Notification.tsx index ad9e529c..cd933259 100644 --- a/src/Notification.tsx +++ b/src/Notification.tsx @@ -66,6 +66,7 @@ export interface NotificationProps { onClick?: React.MouseEventHandler; onMouseEnter?: React.MouseEventHandler; onMouseLeave?: React.MouseEventHandler; + onDestroy?: (hovering: boolean) => void; /** @deprecated Please use `closable.onClose` instead. */ onClose?: () => void; } @@ -102,6 +103,7 @@ const Notification = React.forwardRef((props, onClick, onMouseEnter, onMouseLeave, + onDestroy, onClose, } = props; @@ -158,6 +160,12 @@ const Notification = React.forwardRef((props, onInternalClose(); } + const destroy = useEvent(() => { + onDestroy?.(hovering); + }); + + React.useEffect(() => destroy, [destroy]); + // ======================== Position ======================== const offsetRef = React.useRef(offset); if (offset !== undefined) { diff --git a/src/NotificationList/index.tsx b/src/NotificationList/index.tsx index d0ff04b3..4947eb9c 100644 --- a/src/NotificationList/index.tsx +++ b/src/NotificationList/index.tsx @@ -17,7 +17,7 @@ import Content from './Content'; export type Placement = 'top' | 'topLeft' | 'topRight' | 'bottom' | 'bottomLeft' | 'bottomRight'; export type { StackConfig } from '../hooks/useStack'; -export interface NotificationListConfig extends Omit { +export interface NotificationListConfig extends Omit { key: React.Key; placement?: Placement; times?: number; @@ -108,6 +108,7 @@ interface NotificationListItemProps { pauseOnHover?: boolean; setNodeSize: (key: string, node: HTMLDivElement | null) => void; onNoticeClose?: (key: React.Key) => void; + onDestroy: (hovering: boolean) => void; } const NotificationListItem: React.FC = (props) => { @@ -303,6 +304,11 @@ const NotificationList: React.FC = (props) => { pauseOnHover={pauseOnHover} setNodeSize={setNodeSize} onNoticeClose={onNoticeClose} + onDestroy={(hovering) => { + if (hovering) { + setListHovering(false); + } + }} /> ); }} diff --git a/tests/stack-destroy.test.tsx b/tests/stack-destroy.test.tsx new file mode 100644 index 00000000..208d1d7c --- /dev/null +++ b/tests/stack-destroy.test.tsx @@ -0,0 +1,50 @@ +import { act, fireEvent, render } from '@testing-library/react'; +import React from 'react'; +import { useNotification } from '../src'; +import type { NotificationAPI } from '../src'; + +describe('stack destroy', () => { + it('resumes remaining timers only when a hovered notice is destroyed', () => { + vi.useFakeTimers(); + const step = (time: number) => { + act(() => { + for (let elapsed = 0; elapsed < time; elapsed += 16) { + vi.advanceTimersByTime(16); + } + }); + }; + let api: NotificationAPI; + const Demo = () => { + const [instance, holder] = useNotification({ stack: true }); + api = instance; + return holder; + }; + const { getByText, unmount } = render(); + try { + act(() => { + for (let key = 0; key < 6; key += 1) { + api.open({ key, description: `Notice ${key}`, duration: key === 1 ? 0 : 1 }); + } + }); + step(320); + fireEvent.mouseEnter(getByText('Notice 0')); + act(() => api.close(5)); + step(2000); + expect(document.querySelector('.rc-notification-list-hovered')).not.toBeNull(); + expect(document.querySelectorAll('.rc-notification-notice')).toHaveLength(5); + + // Unmount the hovered notice without dispatching mouseleave. + act(() => api.close(0)); + expect(document.querySelector('.rc-notification-list-hovered')).toBeNull(); + expect(document.querySelector('.rc-notification-stack-expanded')).toBeNull(); + step(400); + expect(document.querySelectorAll('.rc-notification-notice')).toHaveLength(4); + step(400); + expect(document.querySelectorAll('.rc-notification-notice')).toHaveLength(1); + expect(getByText('Notice 1')).toBeInTheDocument(); + } finally { + unmount(); + vi.useRealTimers(); + } + }); +}); From 5ca2b8921f841f6d8eddaf0328e6f4712bc484b4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BA=8C=E8=B4=A7=E6=9C=BA=E5=99=A8=E4=BA=BA?= Date: Tue, 29 Sep 2026 12:01:25 +0800 Subject: [PATCH 2/2] fix: call notification leave on hovered unmount --- src/Notification.tsx | 10 +++--- src/NotificationList/index.tsx | 8 +---- tests/notification-unmount.test.tsx | 31 ++++++++++++++++++ tests/stack-destroy.test.tsx | 50 ----------------------------- 4 files changed, 37 insertions(+), 62 deletions(-) create mode 100644 tests/notification-unmount.test.tsx delete mode 100644 tests/stack-destroy.test.tsx diff --git a/src/Notification.tsx b/src/Notification.tsx index cd933259..0f68c54f 100644 --- a/src/Notification.tsx +++ b/src/Notification.tsx @@ -65,8 +65,7 @@ export interface NotificationProps { // Function onClick?: React.MouseEventHandler; onMouseEnter?: React.MouseEventHandler; - onMouseLeave?: React.MouseEventHandler; - onDestroy?: (hovering: boolean) => void; + onMouseLeave?: (event?: React.MouseEvent) => void; /** @deprecated Please use `closable.onClose` instead. */ onClose?: () => void; } @@ -103,7 +102,6 @@ const Notification = React.forwardRef((props, onClick, onMouseEnter, onMouseLeave, - onDestroy, onClose, } = props; @@ -146,7 +144,7 @@ const Notification = React.forwardRef((props, onMouseEnter?.(event); } - function onInternalMouseLeave(event: React.MouseEvent) { + function onInternalMouseLeave(event?: React.MouseEvent) { setHovering(false); if (pauseOnHover && !forcedHovering) { onResume(); @@ -161,7 +159,9 @@ const Notification = React.forwardRef((props, } const destroy = useEvent(() => { - onDestroy?.(hovering); + if (hovering) { + onInternalMouseLeave(); + } }); React.useEffect(() => destroy, [destroy]); diff --git a/src/NotificationList/index.tsx b/src/NotificationList/index.tsx index 4947eb9c..d0ff04b3 100644 --- a/src/NotificationList/index.tsx +++ b/src/NotificationList/index.tsx @@ -17,7 +17,7 @@ import Content from './Content'; export type Placement = 'top' | 'topLeft' | 'topRight' | 'bottom' | 'bottomLeft' | 'bottomRight'; export type { StackConfig } from '../hooks/useStack'; -export interface NotificationListConfig extends Omit { +export interface NotificationListConfig extends Omit { key: React.Key; placement?: Placement; times?: number; @@ -108,7 +108,6 @@ interface NotificationListItemProps { pauseOnHover?: boolean; setNodeSize: (key: string, node: HTMLDivElement | null) => void; onNoticeClose?: (key: React.Key) => void; - onDestroy: (hovering: boolean) => void; } const NotificationListItem: React.FC = (props) => { @@ -304,11 +303,6 @@ const NotificationList: React.FC = (props) => { pauseOnHover={pauseOnHover} setNodeSize={setNodeSize} onNoticeClose={onNoticeClose} - onDestroy={(hovering) => { - if (hovering) { - setListHovering(false); - } - }} /> ); }} diff --git a/tests/notification-unmount.test.tsx b/tests/notification-unmount.test.tsx new file mode 100644 index 00000000..47ce3d60 --- /dev/null +++ b/tests/notification-unmount.test.tsx @@ -0,0 +1,31 @@ +import { fireEvent, render } from '@testing-library/react'; +import React from 'react'; +import { Notification } from '../src'; + +describe('Notification unmount', () => { + it.each(['hovered', 'never hovered', 'already left'])( + 'only calls leave when still hovered: %s', + (state) => { + const onMouseLeave = vi.fn(); + const { container, unmount } = render( + , + ); + const notice = container.firstElementChild!; + + if (state !== 'never hovered') { + fireEvent.mouseEnter(notice); + } + if (state === 'already left') { + fireEvent.mouseLeave(notice); + expect(onMouseLeave).toHaveBeenCalledTimes(1); + onMouseLeave.mockClear(); + } + + unmount(); + expect(onMouseLeave).toHaveBeenCalledTimes(state === 'hovered' ? 1 : 0); + if (state === 'hovered') { + expect(onMouseLeave).toHaveBeenCalledWith(undefined); + } + }, + ); +}); diff --git a/tests/stack-destroy.test.tsx b/tests/stack-destroy.test.tsx deleted file mode 100644 index 208d1d7c..00000000 --- a/tests/stack-destroy.test.tsx +++ /dev/null @@ -1,50 +0,0 @@ -import { act, fireEvent, render } from '@testing-library/react'; -import React from 'react'; -import { useNotification } from '../src'; -import type { NotificationAPI } from '../src'; - -describe('stack destroy', () => { - it('resumes remaining timers only when a hovered notice is destroyed', () => { - vi.useFakeTimers(); - const step = (time: number) => { - act(() => { - for (let elapsed = 0; elapsed < time; elapsed += 16) { - vi.advanceTimersByTime(16); - } - }); - }; - let api: NotificationAPI; - const Demo = () => { - const [instance, holder] = useNotification({ stack: true }); - api = instance; - return holder; - }; - const { getByText, unmount } = render(); - try { - act(() => { - for (let key = 0; key < 6; key += 1) { - api.open({ key, description: `Notice ${key}`, duration: key === 1 ? 0 : 1 }); - } - }); - step(320); - fireEvent.mouseEnter(getByText('Notice 0')); - act(() => api.close(5)); - step(2000); - expect(document.querySelector('.rc-notification-list-hovered')).not.toBeNull(); - expect(document.querySelectorAll('.rc-notification-notice')).toHaveLength(5); - - // Unmount the hovered notice without dispatching mouseleave. - act(() => api.close(0)); - expect(document.querySelector('.rc-notification-list-hovered')).toBeNull(); - expect(document.querySelector('.rc-notification-stack-expanded')).toBeNull(); - step(400); - expect(document.querySelectorAll('.rc-notification-notice')).toHaveLength(4); - step(400); - expect(document.querySelectorAll('.rc-notification-notice')).toHaveLength(1); - expect(getByText('Notice 1')).toBeInTheDocument(); - } finally { - unmount(); - vi.useRealTimers(); - } - }); -});