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,