From ddfa0c8d3d12763ef27fd52ffaf413d9b6d85e0b Mon Sep 17 00:00:00 2001 From: aojunhao123 <1844749591@qq.com> Date: Tue, 30 Dec 2025 22:20:20 +0800 Subject: [PATCH 1/5] chore: add IME lock to prevent unintended close --- src/useEscKeyDown.ts | 19 +++++++++++++++++-- tests/index.test.tsx | 26 ++++++++++++++++++++++++++ 2 files changed, 43 insertions(+), 2 deletions(-) diff --git a/src/useEscKeyDown.ts b/src/useEscKeyDown.ts index 31b2cb9..539a39a 100644 --- a/src/useEscKeyDown.ts +++ b/src/useEscKeyDown.ts @@ -1,20 +1,33 @@ -import { useEffect, useMemo } from 'react'; +import { useEffect, useMemo, useRef } from 'react'; import { type EscCallback } from './Portal'; import useId from '@rc-component/util/lib/hooks/useId'; import { useEvent } from '@rc-component/util'; -export let stack: string[] = []; // export for testing +export let stack: string[] = []; + +const IME_LOCK_DURATION = 200; export default function useEscKeyDown(open: boolean, onEsc?: EscCallback) { const id = useId(); + const compositionEndTimeRef = useRef(0); + const handleEscKeyDown = useEvent((event: KeyboardEvent) => { if (event.key === 'Escape' && !event.isComposing) { + const now = Date.now(); + if (now - compositionEndTimeRef.current < IME_LOCK_DURATION) { + return; + } + const top = stack[stack.length - 1] === id; onEsc?.({ top, event }); } }); + const handleCompositionEnd = useEvent(() => { + compositionEndTimeRef.current = Date.now(); + }); + useMemo(() => { if (open && !stack.includes(id)) { stack.push(id); @@ -33,10 +46,12 @@ export default function useEscKeyDown(open: boolean, onEsc?: EscCallback) { } window.addEventListener('keydown', handleEscKeyDown); + window.addEventListener('compositionend', handleCompositionEnd); return () => { stack = stack.filter(item => item !== id); window.removeEventListener('keydown', handleEscKeyDown); + window.removeEventListener('compositionend', handleCompositionEnd); }; }, [open, id]); } diff --git a/tests/index.test.tsx b/tests/index.test.tsx index db50b20..6fc3cce 100644 --- a/tests/index.test.tsx +++ b/tests/index.test.tsx @@ -370,6 +370,32 @@ describe('Portal', () => { expect(onEsc).not.toHaveBeenCalled(); }); + it('should not trigger onEsc within IME lock duration after compositionend', () => { + jest.useFakeTimers(); + const onEsc = jest.fn(); + + render( + + + , + ); + + fireEvent.compositionEnd(window); + + fireEvent.keyDown(window, { key: 'Escape' }); + expect(onEsc).not.toHaveBeenCalled(); + + jest.advanceTimersByTime(100); + fireEvent.keyDown(window, { key: 'Escape' }); + expect(onEsc).not.toHaveBeenCalled(); + + jest.advanceTimersByTime(150); + fireEvent.keyDown(window, { key: 'Escape' }); + expect(onEsc).toHaveBeenCalledWith(expect.objectContaining({ top: true })); + + jest.useRealTimers(); + }); + it('should clear stack when unmount', () => { const { unmount } = render( From 1bb9ceb7ab156b01bc4b73f211f50141e4fec649 Mon Sep 17 00:00:00 2001 From: aojunhao123 <1844749591@qq.com> Date: Tue, 30 Dec 2025 22:22:07 +0800 Subject: [PATCH 2/5] update --- src/useEscKeyDown.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/useEscKeyDown.ts b/src/useEscKeyDown.ts index 539a39a..79a3836 100644 --- a/src/useEscKeyDown.ts +++ b/src/useEscKeyDown.ts @@ -3,7 +3,7 @@ import { type EscCallback } from './Portal'; import useId from '@rc-component/util/lib/hooks/useId'; import { useEvent } from '@rc-component/util'; -export let stack: string[] = []; +export let stack: string[] = []; // export for testing const IME_LOCK_DURATION = 200; From dd9081a62c661a5d87beebe1b60f326be7ce94ab Mon Sep 17 00:00:00 2001 From: aojunhao123 <1844749591@qq.com> Date: Tue, 30 Dec 2025 23:17:03 +0800 Subject: [PATCH 3/5] global lock for esc --- src/useEscKeyDown.ts | 13 ++++++++----- tests/index.test.tsx | 5 ++++- 2 files changed, 12 insertions(+), 6 deletions(-) diff --git a/src/useEscKeyDown.ts b/src/useEscKeyDown.ts index 79a3836..88af651 100644 --- a/src/useEscKeyDown.ts +++ b/src/useEscKeyDown.ts @@ -1,4 +1,4 @@ -import { useEffect, useMemo, useRef } from 'react'; +import { useEffect, useMemo } from 'react'; import { type EscCallback } from './Portal'; import useId from '@rc-component/util/lib/hooks/useId'; import { useEvent } from '@rc-component/util'; @@ -6,16 +6,19 @@ import { useEvent } from '@rc-component/util'; export let stack: string[] = []; // export for testing const IME_LOCK_DURATION = 200; +let lastCompositionEndTime = 0; + +export function resetEscKeyDownLock() { + lastCompositionEndTime = 0; +} export default function useEscKeyDown(open: boolean, onEsc?: EscCallback) { const id = useId(); - const compositionEndTimeRef = useRef(0); - const handleEscKeyDown = useEvent((event: KeyboardEvent) => { if (event.key === 'Escape' && !event.isComposing) { const now = Date.now(); - if (now - compositionEndTimeRef.current < IME_LOCK_DURATION) { + if (now - lastCompositionEndTime < IME_LOCK_DURATION) { return; } @@ -25,7 +28,7 @@ export default function useEscKeyDown(open: boolean, onEsc?: EscCallback) { }); const handleCompositionEnd = useEvent(() => { - compositionEndTimeRef.current = Date.now(); + lastCompositionEndTime = Date.now(); }); useMemo(() => { diff --git a/tests/index.test.tsx b/tests/index.test.tsx index 6fc3cce..4194dcd 100644 --- a/tests/index.test.tsx +++ b/tests/index.test.tsx @@ -1,7 +1,7 @@ import { render, fireEvent } from '@testing-library/react'; import React from 'react'; import Portal from '../src'; -import { stack } from '../src/useEscKeyDown'; +import { resetEscKeyDownLock, stack } from '../src/useEscKeyDown'; global.isOverflow = true; @@ -28,6 +28,9 @@ describe('Portal', () => { beforeEach(() => { global.isOverflow = true; }); + afterEach(() => { + resetEscKeyDownLock(); + }); it('Order', () => { render( From 988906be22f1a01fe7d91e846e7d0fa5712ce801 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: Wed, 31 Dec 2025 15:07:50 +0800 Subject: [PATCH 4/5] chore: clean up --- src/useEscKeyDown.ts | 105 ++++++++++++++++++++++++++++--------------- tests/index.test.tsx | 59 ++++++++++++++---------- 2 files changed, 105 insertions(+), 59 deletions(-) diff --git a/src/useEscKeyDown.ts b/src/useEscKeyDown.ts index 88af651..413bfb7 100644 --- a/src/useEscKeyDown.ts +++ b/src/useEscKeyDown.ts @@ -1,60 +1,93 @@ +import { useEvent } from '@rc-component/util'; +import useId from '@rc-component/util/lib/hooks/useId'; import { useEffect, useMemo } from 'react'; import { type EscCallback } from './Portal'; -import useId from '@rc-component/util/lib/hooks/useId'; -import { useEvent } from '@rc-component/util'; -export let stack: string[] = []; // export for testing +let stack: { id: string; onEsc?: EscCallback }[] = []; const IME_LOCK_DURATION = 200; let lastCompositionEndTime = 0; -export function resetEscKeyDownLock() { - lastCompositionEndTime = 0; +// Export for testing +export const _test = + process.env.NODE_ENV === 'test' + ? () => ({ + stack, + reset: () => { + // Not reset stack to ensure effect will clean up correctly + lastCompositionEndTime = 0; + }, + }) + : null; + +// Global event handlers +const onGlobalKeyDown = (event: KeyboardEvent) => { + if (event.key === 'Escape' && !event.isComposing) { + const now = Date.now(); + if (now - lastCompositionEndTime < IME_LOCK_DURATION) { + return; + } + + const len = stack.length; + for (let i = 0; i < len; i += 1) { + stack[i].onEsc({ + top: i === len - 1, + event, + }); + } + } +}; + +const onGlobalCompositionEnd = () => { + lastCompositionEndTime = Date.now(); +}; + +function attachGlobalEventListeners() { + window.addEventListener('keydown', onGlobalKeyDown); + window.addEventListener('compositionend', onGlobalCompositionEnd); +} + +function detachGlobalEventListeners() { + if (stack.length === 0) { + window.removeEventListener('keydown', onGlobalKeyDown); + window.removeEventListener('compositionend', onGlobalCompositionEnd); + } } export default function useEscKeyDown(open: boolean, onEsc?: EscCallback) { const id = useId(); - const handleEscKeyDown = useEvent((event: KeyboardEvent) => { - if (event.key === 'Escape' && !event.isComposing) { - const now = Date.now(); - if (now - lastCompositionEndTime < IME_LOCK_DURATION) { - return; - } + const onEventEsc = useEvent(onEsc); - const top = stack[stack.length - 1] === id; - onEsc?.({ top, event }); + const ensure = () => { + if (!stack.find(item => item.id === id)) { + stack.push({ id, onEsc: onEventEsc }); } - }); + }; - const handleCompositionEnd = useEvent(() => { - lastCompositionEndTime = Date.now(); - }); + const clear = () => { + stack = stack.filter(item => item.id !== id); + }; useMemo(() => { - if (open && !stack.includes(id)) { - stack.push(id); + if (open) { + ensure(); } else if (!open) { - stack = stack.filter(item => item !== id); + clear(); } - }, [open, id]); + }, [open]); useEffect(() => { - if (open && !stack.includes(id)) { - stack.push(id); - } + if (open) { + ensure(); + // Attach global event listeners + attachGlobalEventListeners(); - if (!open) { - return; + return () => { + clear(); + // Remove global event listeners if instances is empty + detachGlobalEventListeners(); + }; } - - window.addEventListener('keydown', handleEscKeyDown); - window.addEventListener('compositionend', handleCompositionEnd); - - return () => { - stack = stack.filter(item => item !== id); - window.removeEventListener('keydown', handleEscKeyDown); - window.removeEventListener('compositionend', handleCompositionEnd); - }; - }, [open, id]); + }, [open]); } diff --git a/tests/index.test.tsx b/tests/index.test.tsx index 4194dcd..bc2b3a0 100644 --- a/tests/index.test.tsx +++ b/tests/index.test.tsx @@ -1,7 +1,7 @@ -import { render, fireEvent } from '@testing-library/react'; +import { fireEvent, render } from '@testing-library/react'; import React from 'react'; import Portal from '../src'; -import { resetEscKeyDownLock, stack } from '../src/useEscKeyDown'; +import { _test } from '../src/useEscKeyDown'; global.isOverflow = true; @@ -28,9 +28,6 @@ describe('Portal', () => { beforeEach(() => { global.isOverflow = true; }); - afterEach(() => { - resetEscKeyDownLock(); - }); it('Order', () => { render( @@ -301,6 +298,16 @@ describe('Portal', () => { }); describe('onEsc', () => { + beforeEach(() => { + _test().reset(); + jest.useFakeTimers(); + }); + + afterEach(() => { + jest.clearAllTimers(); + jest.useRealTimers(); + }); + it('only last opened portal is top', () => { const onEscA = jest.fn(); const onEscB = jest.fn(); @@ -374,7 +381,6 @@ describe('Portal', () => { }); it('should not trigger onEsc within IME lock duration after compositionend', () => { - jest.useFakeTimers(); const onEsc = jest.fn(); render( @@ -394,9 +400,9 @@ describe('Portal', () => { jest.advanceTimersByTime(150); fireEvent.keyDown(window, { key: 'Escape' }); - expect(onEsc).toHaveBeenCalledWith(expect.objectContaining({ top: true })); - - jest.useRealTimers(); + expect(onEsc).toHaveBeenCalledWith( + expect.objectContaining({ top: true }), + ); }); it('should clear stack when unmount', () => { @@ -406,28 +412,30 @@ describe('Portal', () => { , ); - expect(stack).toHaveLength(1); + expect(_test().stack).toHaveLength(1); unmount(); - expect(stack).toHaveLength(0); + expect(_test().stack).toHaveLength(0); }); it('onEsc should treat first mounted portal as top in StrictMode', () => { const onEsc = jest.fn(); - + const Demo = ({ visible }: { visible: boolean }) => visible ? (
) : null; - + render(, { wrapper: React.StrictMode }); - - expect(stack).toHaveLength(1); - + + expect(_test().stack).toHaveLength(1); + fireEvent.keyDown(window, { key: 'Escape' }); - - expect(onEsc).toHaveBeenCalledWith(expect.objectContaining({ top: true })); + + expect(onEsc).toHaveBeenCalledWith( + expect.objectContaining({ top: true }), + ); }); it('nested portals should trigger in correct order', () => { @@ -444,15 +452,20 @@ describe('Portal', () => {
- + , ); fireEvent.keyDown(window, { key: 'Escape' }); - expect(onEsc).toHaveBeenCalledWith(expect.objectContaining({ top: false })); - expect(onEsc2).toHaveBeenCalledWith(expect.objectContaining({ top: false })); - expect(onEsc3).toHaveBeenCalledWith(expect.objectContaining({ top: true })); + expect(onEsc).toHaveBeenCalledWith( + expect.objectContaining({ top: false }), + ); + expect(onEsc2).toHaveBeenCalledWith( + expect.objectContaining({ top: false }), + ); + expect(onEsc3).toHaveBeenCalledWith( + expect.objectContaining({ top: true }), + ); }); - }); }); From b09a597369e315e64815d5fc054812db92104723 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: Wed, 31 Dec 2025 15:08:52 +0800 Subject: [PATCH 5/5] chore: clean up --- src/useEscKeyDown.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/useEscKeyDown.ts b/src/useEscKeyDown.ts index 413bfb7..fc99020 100644 --- a/src/useEscKeyDown.ts +++ b/src/useEscKeyDown.ts @@ -29,7 +29,7 @@ const onGlobalKeyDown = (event: KeyboardEvent) => { } const len = stack.length; - for (let i = 0; i < len; i += 1) { + for (let i = len - 1; i >= 0; i -= 1) { stack[i].onEsc({ top: i === len - 1, event,