From 67e931ef962bd19bdbe44c4459a6ed3ac0df58be Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=AB=98=E8=89=B3=E5=85=B5?= Date: Tue, 30 Dec 2025 09:58:19 +0800 Subject: [PATCH 1/2] fix(trigger): avoid render-based reset for interaction-level deduplication --- src/index.tsx | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/src/index.tsx b/src/index.tsx index 789831d4..8bc17890 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -372,9 +372,18 @@ export function generateTrigger( openRef.current = mergedOpen; const lastTriggerRef = React.useRef([]); - lastTriggerRef.current = []; + const resetScheduledRef = React.useRef(false); const internalTriggerOpen = useEvent((nextOpen: boolean) => { + // `lastTriggerRef` is for interaction-level deduplication; do not reset it on render. + if (!resetScheduledRef.current) { + resetScheduledRef.current = true; + queueMicrotask(() => { + resetScheduledRef.current = false; + lastTriggerRef.current = []; + }); + } + setInternalOpen(nextOpen); // Enter or Pointer will both trigger open state change From 543157772a11433efbb7016f666c32148ee5aeda Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=AB=98=E8=89=B3=E5=85=B5?= Date: Sun, 4 Jan 2026 16:52:34 +0800 Subject: [PATCH 2/2] fix(trigger): dedupe open change callbacks --- src/index.tsx | 33 ++------ tests/open-change.test.tsx | 150 +++++++++++++++++++++++++++++++++++++ 2 files changed, 158 insertions(+), 25 deletions(-) create mode 100644 tests/open-change.test.tsx diff --git a/src/index.tsx b/src/index.tsx index 8bc17890..54c07caa 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -34,6 +34,7 @@ export type { import UniqueProvider, { type UniqueProviderProps } from './UniqueProvider'; import { useControlledState } from '@rc-component/util'; +import { flushSync } from 'react-dom'; export { UniqueProvider }; export type { UniqueProviderProps }; @@ -371,32 +372,14 @@ export function generateTrigger( const openRef = React.useRef(mergedOpen); openRef.current = mergedOpen; - const lastTriggerRef = React.useRef([]); - const resetScheduledRef = React.useRef(false); - const internalTriggerOpen = useEvent((nextOpen: boolean) => { - // `lastTriggerRef` is for interaction-level deduplication; do not reset it on render. - if (!resetScheduledRef.current) { - resetScheduledRef.current = true; - queueMicrotask(() => { - resetScheduledRef.current = false; - lastTriggerRef.current = []; - }); - } - - setInternalOpen(nextOpen); - - // Enter or Pointer will both trigger open state change - // We only need take one to avoid duplicated change event trigger - // Use `lastTriggerRef` to record last open type - if ( - (lastTriggerRef.current[lastTriggerRef.current.length - 1] ?? - mergedOpen) !== nextOpen - ) { - lastTriggerRef.current.push(nextOpen); - onOpenChange?.(nextOpen); - onPopupVisibleChange?.(nextOpen); - } + flushSync(() => { + if (mergedOpen !== nextOpen) { + setInternalOpen(nextOpen); + onOpenChange?.(nextOpen); + onPopupVisibleChange?.(nextOpen); + } + }); }); // Trigger for delay diff --git a/tests/open-change.test.tsx b/tests/open-change.test.tsx new file mode 100644 index 00000000..aa6c4c74 --- /dev/null +++ b/tests/open-change.test.tsx @@ -0,0 +1,150 @@ +import { act, cleanup, fireEvent, render } from '@testing-library/react'; +import { spyElementPrototypes } from '@rc-component/util/lib/test/domHook'; +import * as React from 'react'; +import Trigger from '../src'; + +const flush = async () => { + for (let i = 0; i < 10; i += 1) { + act(() => { + jest.runAllTimers(); + }); + + await act(async () => { + await Promise.resolve(); + }); + } +}; + +describe('Trigger.OpenChange', () => { + let eleRect = { + width: 100, + height: 100, + }; + + let spanRect = { + x: 0, + y: 0, + left: 0, + top: 0, + width: 1, + height: 1, + }; + + let popupRect = { + x: 0, + y: 0, + left: 0, + top: 0, + width: 100, + height: 100, + }; + + beforeAll(() => { + // Keep consistent with other tests to avoid layout related crash in jsdom + spyElementPrototypes(HTMLElement, { + clientWidth: { + get: () => eleRect.width, + }, + clientHeight: { + get: () => eleRect.height, + }, + offsetWidth: { + get: () => eleRect.width, + }, + offsetHeight: { + get: () => eleRect.height, + }, + offsetParent: { + get: () => document.body, + }, + }); + + spyElementPrototypes(HTMLDivElement, { + getBoundingClientRect() { + return popupRect; + }, + }); + + spyElementPrototypes(HTMLSpanElement, { + getBoundingClientRect() { + return spanRect; + }, + }); + }); + + beforeEach(() => { + eleRect = { width: 100, height: 100 }; + spanRect = { x: 0, y: 0, left: 0, top: 0, width: 1, height: 1 }; + popupRect = { x: 0, y: 0, left: 0, top: 0, width: 100, height: 100 }; + jest.useFakeTimers(); + }); + + afterEach(() => { + cleanup(); + jest.useRealTimers(); + }); + + it('should not trigger duplicated open callbacks when pointer and focus happen in same interaction', async () => { + const onOpenChange = jest.fn(); + const onPopupVisibleChange = jest.fn(); + + const { container } = render( + trigger} + onOpenChange={onOpenChange} + onPopupVisibleChange={onPopupVisibleChange} + > + + , + ); + + const target = container.querySelector('.target') as HTMLElement; + + act(() => { + fireEvent.pointerEnter(target); + fireEvent.focus(target); + }); + + await flush(); + + expect(onOpenChange).toHaveBeenCalledTimes(1); + expect(onOpenChange).toHaveBeenLastCalledWith(true); + + expect(onPopupVisibleChange).toHaveBeenCalledTimes(1); + expect(onPopupVisibleChange).toHaveBeenLastCalledWith(true); + }); + + it('should not trigger duplicated close callbacks when pointerleave and blur happen in same interaction', async () => { + const onOpenChange = jest.fn(); + const onPopupVisibleChange = jest.fn(); + + const { container } = render( + trigger} + defaultPopupVisible + onOpenChange={onOpenChange} + onPopupVisibleChange={onPopupVisibleChange} + > + + , + ); + + const target = container.querySelector('.target') as HTMLElement; + + act(() => { + fireEvent.pointerLeave(target); + fireEvent.blur(target); + }); + + await flush(); + + expect(onOpenChange).toHaveBeenCalledTimes(1); + expect(onOpenChange).toHaveBeenLastCalledWith(false); + + expect(onPopupVisibleChange).toHaveBeenCalledTimes(1); + expect(onPopupVisibleChange).toHaveBeenLastCalledWith(false); + }); +});