From b3c0dc621e6cc6fc1be0ba3328bf10ad9876b562 Mon Sep 17 00:00:00 2001 From: yoyo837 Date: Tue, 17 Mar 2026 14:59:48 +0800 Subject: [PATCH 1/7] fix: close preview on Escape in nested portal contexts --- src/Preview/index.tsx | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/src/Preview/index.tsx b/src/Preview/index.tsx index 05594fc..4aa1396 100644 --- a/src/Preview/index.tsx +++ b/src/Preview/index.tsx @@ -330,7 +330,14 @@ const Preview: React.FC = props => { // >>>>> Effect: Keyboard const onKeyDown = useEvent((event: KeyboardEvent) => { if (open) { - const { keyCode } = event; + const { keyCode, key } = event; + + if (keyCode === KeyCode.ESC || key === 'Escape') { + event.preventDefault(); + event.stopPropagation(); + onClose?.(); + return; + } if (showLeftOrRightSwitches) { if (keyCode === KeyCode.LEFT) { @@ -376,12 +383,6 @@ const Preview: React.FC = props => { } }, [open]); - const onEsc: PortalProps['onEsc'] = ({ top }) => { - if (top) { - onClose?.(); - } - }; - // ========================== Render ========================== const bodyStyle: React.CSSProperties = { ...styles.body, @@ -396,7 +397,6 @@ const Preview: React.FC = props => { autoDestroy={false} getContainer={getContainer} autoLock={lockScroll} - onEsc={onEsc} > Date: Tue, 17 Mar 2026 15:16:15 +0800 Subject: [PATCH 2/7] fix: use capture phase and stopImmediatePropagation for ESC key handling - Register keydown listener in capture phase (true) so Preview handler fires before any bubble-phase listeners (e.g. parent Dialog) - Replace stopPropagation with stopImmediatePropagation to also block other capture-phase listeners registered after this one Co-Authored-By: Claude Sonnet 4.6 --- src/Preview/index.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/Preview/index.tsx b/src/Preview/index.tsx index 4aa1396..e7d139a 100644 --- a/src/Preview/index.tsx +++ b/src/Preview/index.tsx @@ -334,7 +334,7 @@ const Preview: React.FC = props => { if (keyCode === KeyCode.ESC || key === 'Escape') { event.preventDefault(); - event.stopPropagation(); + event.stopImmediatePropagation(); onClose?.(); return; } @@ -351,10 +351,10 @@ const Preview: React.FC = props => { useEffect(() => { if (open) { - window.addEventListener('keydown', onKeyDown); + window.addEventListener('keydown', onKeyDown, true); return () => { - window.removeEventListener('keydown', onKeyDown); + window.removeEventListener('keydown', onKeyDown, true); }; } }, [open]); From be597289bce61002850a49dbad91a2c191d87936 Mon Sep 17 00:00:00 2001 From: yoyo837 Date: Tue, 17 Mar 2026 15:48:11 +0800 Subject: [PATCH 3/7] fix: remove stopImmediatePropagation to allow Portal stack to handle Dialog ESC Portal's onGlobalKeyDown uses a stack with top-check to prevent Dialog from closing when Preview is on top. stopImmediatePropagation was blocking this mechanism entirely, causing Dialog's onClose to never fire on second ESC. Co-Authored-By: Claude Sonnet 4.6 --- src/Preview/index.tsx | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/src/Preview/index.tsx b/src/Preview/index.tsx index e7d139a..0e8db95 100644 --- a/src/Preview/index.tsx +++ b/src/Preview/index.tsx @@ -334,7 +334,6 @@ const Preview: React.FC = props => { if (keyCode === KeyCode.ESC || key === 'Escape') { event.preventDefault(); - event.stopImmediatePropagation(); onClose?.(); return; } @@ -351,10 +350,10 @@ const Preview: React.FC = props => { useEffect(() => { if (open) { - window.addEventListener('keydown', onKeyDown, true); + window.addEventListener('keydown', onKeyDown); return () => { - window.removeEventListener('keydown', onKeyDown, true); + window.removeEventListener('keydown', onKeyDown); }; } }, [open]); From d1bbe3b237420fcb83ecdf5f6878975bd7e8c653 Mon Sep 17 00:00:00 2001 From: yoyo837 Date: Tue, 17 Mar 2026 16:00:03 +0800 Subject: [PATCH 4/7] fix: keep portal onEsc fallback without double close --- src/Preview/index.tsx | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/src/Preview/index.tsx b/src/Preview/index.tsx index 0e8db95..442fb25 100644 --- a/src/Preview/index.tsx +++ b/src/Preview/index.tsx @@ -327,14 +327,21 @@ const Preview: React.FC = props => { } }; + const escClosingRef = useRef(false); + // >>>>> Effect: Keyboard const onKeyDown = useEvent((event: KeyboardEvent) => { if (open) { const { keyCode, key } = event; if (keyCode === KeyCode.ESC || key === 'Escape') { + escClosingRef.current = true; event.preventDefault(); onClose?.(); + + Promise.resolve().then(() => { + escClosingRef.current = false; + }); return; } @@ -382,6 +389,12 @@ const Preview: React.FC = props => { } }, [open]); + const onEsc: PortalProps['onEsc'] = ({ top }) => { + if (top && !escClosingRef.current) { + onClose?.(); + } + }; + // ========================== Render ========================== const bodyStyle: React.CSSProperties = { ...styles.body, @@ -396,6 +409,7 @@ const Preview: React.FC = props => { autoDestroy={false} getContainer={getContainer} autoLock={lockScroll} + onEsc={onEsc} > Date: Tue, 17 Mar 2026 16:14:17 +0800 Subject: [PATCH 5/7] test: cover esc fallback and capture-phase listener --- src/Preview/index.tsx | 4 +- tests/preview.portal.test.tsx | 75 +++++++++++++++++++++++++++++++++++ 2 files changed, 77 insertions(+), 2 deletions(-) create mode 100644 tests/preview.portal.test.tsx diff --git a/src/Preview/index.tsx b/src/Preview/index.tsx index 442fb25..866be6d 100644 --- a/src/Preview/index.tsx +++ b/src/Preview/index.tsx @@ -357,10 +357,10 @@ const Preview: React.FC = props => { useEffect(() => { if (open) { - window.addEventListener('keydown', onKeyDown); + window.addEventListener('keydown', onKeyDown, true); return () => { - window.removeEventListener('keydown', onKeyDown); + window.removeEventListener('keydown', onKeyDown, true); }; } }, [open]); diff --git a/tests/preview.portal.test.tsx b/tests/preview.portal.test.tsx new file mode 100644 index 0000000..9daf140 --- /dev/null +++ b/tests/preview.portal.test.tsx @@ -0,0 +1,75 @@ +import { act, fireEvent, render } from '@testing-library/react'; +import React from 'react'; + +jest.mock('@rc-component/motion', () => { + const MockCSSMotion = ({ children }: any) => children({ className: '', style: {} }); + return { + __esModule: true, + default: MockCSSMotion, + }; +}); + +jest.mock('@rc-component/portal', () => { + const React = require('react'); + + const MockPortal = (props: any) => { + (global as any).__portalProps = props; + return <>{props.children}; + }; + + return { + __esModule: true, + default: MockPortal, + }; +}); + +import Preview from '../src/Preview'; + +describe('Preview portal esc fallback', () => { + it('uses capture phase for window keydown listener', () => { + const addSpy = jest.spyOn(window, 'addEventListener'); + const removeSpy = jest.spyOn(window, 'removeEventListener'); + + const { unmount } = render( + , + ); + + expect(addSpy).toHaveBeenCalledWith('keydown', expect.any(Function), true); + + unmount(); + expect(removeSpy).toHaveBeenCalledWith('keydown', expect.any(Function), true); + + addSpy.mockRestore(); + removeSpy.mockRestore(); + }); + + it('keeps portal onEsc as fallback', () => { + const onClose = jest.fn(); + + render( + , + ); + + act(() => { + (global as any).__portalProps.onEsc({ top: true }); + }); + + expect(onClose).toHaveBeenCalledTimes(1); + }); + + it('avoids duplicate close when keydown esc already handled', () => { + const onClose = jest.fn(); + + render( + , + ); + + fireEvent.keyDown(window, { key: 'Escape', keyCode: 27 }); + + act(() => { + (global as any).__portalProps.onEsc({ top: true }); + }); + + expect(onClose).toHaveBeenCalledTimes(1); + }); +}); From b59d9b06ecb7d30edf9b70d045a52eaec2130eb1 Mon Sep 17 00:00:00 2001 From: yoyo837 Date: Tue, 17 Mar 2026 16:48:48 +0800 Subject: [PATCH 6/7] fix: refine esc propagation and split key/keyCode tests --- src/Preview/index.tsx | 16 +++++++++++++--- tests/preview.portal.test.tsx | 20 ++++++++++++++++++-- 2 files changed, 31 insertions(+), 5 deletions(-) diff --git a/src/Preview/index.tsx b/src/Preview/index.tsx index 866be6d..9351317 100644 --- a/src/Preview/index.tsx +++ b/src/Preview/index.tsx @@ -328,20 +328,30 @@ const Preview: React.FC = props => { }; const escClosingRef = useRef(false); + const openRef = useRef(open); + openRef.current = open; // >>>>> Effect: Keyboard const onKeyDown = useEvent((event: KeyboardEvent) => { - if (open) { + if (openRef.current) { const { keyCode, key } = event; if (keyCode === KeyCode.ESC || key === 'Escape') { + if (escClosingRef.current) { + return; + } + escClosingRef.current = true; + openRef.current = false; event.preventDefault(); + if (keyCode === KeyCode.ESC) { + event.stopPropagation(); + } onClose?.(); - Promise.resolve().then(() => { + setTimeout(() => { escClosingRef.current = false; - }); + }, 0); return; } diff --git a/tests/preview.portal.test.tsx b/tests/preview.portal.test.tsx index 9daf140..54aec53 100644 --- a/tests/preview.portal.test.tsx +++ b/tests/preview.portal.test.tsx @@ -57,14 +57,30 @@ describe('Preview portal esc fallback', () => { expect(onClose).toHaveBeenCalledTimes(1); }); - it('avoids duplicate close when keydown esc already handled', () => { + it('avoids duplicate close when keydown esc already handled (key only)', () => { const onClose = jest.fn(); render( , ); - fireEvent.keyDown(window, { key: 'Escape', keyCode: 27 }); + fireEvent.keyDown(window, { key: 'Escape' }); + + act(() => { + (global as any).__portalProps.onEsc({ top: true }); + }); + + expect(onClose).toHaveBeenCalledTimes(1); + }); + + it('avoids duplicate close when keydown esc already handled (keyCode only)', () => { + const onClose = jest.fn(); + + render( + , + ); + + fireEvent.keyDown(window, { keyCode: 27 }); act(() => { (global as any).__portalProps.onEsc({ top: true }); From fb3f07069b16f4000857c203e48122d1216d2640 Mon Sep 17 00:00:00 2001 From: yoyo837 Date: Tue, 17 Mar 2026 16:52:28 +0800 Subject: [PATCH 7/7] refactor: simplify esc guard to improve determinism --- src/Preview/index.tsx | 9 +-------- 1 file changed, 1 insertion(+), 8 deletions(-) diff --git a/src/Preview/index.tsx b/src/Preview/index.tsx index 9351317..845be9a 100644 --- a/src/Preview/index.tsx +++ b/src/Preview/index.tsx @@ -337,10 +337,6 @@ const Preview: React.FC = props => { const { keyCode, key } = event; if (keyCode === KeyCode.ESC || key === 'Escape') { - if (escClosingRef.current) { - return; - } - escClosingRef.current = true; openRef.current = false; event.preventDefault(); @@ -348,10 +344,6 @@ const Preview: React.FC = props => { event.stopPropagation(); } onClose?.(); - - setTimeout(() => { - escClosingRef.current = false; - }, 0); return; } @@ -387,6 +379,7 @@ const Preview: React.FC = props => { const onVisibleChanged = (nextVisible: boolean) => { if (!nextVisible) { setLockScroll(false); + escClosingRef.current = false; } afterOpenChange?.(nextVisible); };