From 4e9cd71fa216af6b185b149c0d8aa1e222714804 Mon Sep 17 00:00:00 2001 From: Matt Van Horn <455140+mvanhorn@users.noreply.github.com> Date: Fri, 20 Mar 2026 18:53:27 -0700 Subject: [PATCH 1/2] feat: use CloseWatcher API for overlay dismiss in supported browsers Add CloseWatcher support to useOverlay for Escape key and Android back button dismiss. Each overlay creates its own CloseWatcher instance - the browser internally stacks watchers so Escape dismisses the most recently created one first, matching the visibleOverlays stack order. The existing onKeyDown handler is kept as a fallback. If CloseWatcher fires first, onHide is a no-op because the overlay is already removed from visibleOverlays. This ensures compatibility with test environments and browsers that don't support CloseWatcher. Uses useEffectEvent for the CloseWatcher callback so the watcher doesn't need to be recreated when onClose changes. Co-Authored-By: Claude Opus 4.6 --- .../react-aria/src/overlays/useOverlay.ts | 40 +++++++-- .../test/overlays/useOverlay.test.js | 90 +++++++++++++++++++ 2 files changed, 122 insertions(+), 8 deletions(-) diff --git a/packages/react-aria/src/overlays/useOverlay.ts b/packages/react-aria/src/overlays/useOverlay.ts index e660df06116..695865203f3 100644 --- a/packages/react-aria/src/overlays/useOverlay.ts +++ b/packages/react-aria/src/overlays/useOverlay.ts @@ -14,6 +14,7 @@ import {DOMAttributes, RefObject} from '@react-types/shared'; import {getEventTarget} from '../utils/shadowdom/DOMFunctions'; import {isElementInChildOfActiveScope} from '../focus/FocusScope'; import {useEffect, useRef} from 'react'; +import {useEffectEvent} from '../utils/useEffectEvent'; import {useFocusWithin} from '../interactions/useFocusWithin'; import {useInteractOutside} from '../interactions/useInteractOutside'; @@ -57,6 +58,10 @@ export interface OverlayAria { const visibleOverlays: RefObject[] = []; +function supportsCloseWatcher(): boolean { + return typeof globalThis.CloseWatcher !== 'undefined'; +} + /** * Provides the behavior for overlays such as dialogs, popovers, and menus. * Hides the overlay when the user interacts outside it, when the Escape key is pressed, @@ -74,25 +79,44 @@ export function useOverlay(props: AriaOverlayProps, ref: RefObject>(undefined); + // Only hide the overlay when it is the topmost visible overlay in the stack + let onHide = () => { + if (visibleOverlays[visibleOverlays.length - 1] === ref && onClose) { + onClose(); + } + }; + + // Stable callback for CloseWatcher that always calls the latest onHide. + // useEffectEvent returns a stable reference, so the watcher doesn't need + // to be recreated when onClose changes. + let onHideEvent = useEffectEvent(onHide); + // Add the overlay ref to the stack of visible overlays on mount, and remove on unmount. + // When CloseWatcher is supported, each overlay gets its own instance. The browser + // internally stacks watchers so Escape dismisses the most recently created one first, + // which also handles the Android back button. The onKeyDown handler below is kept as + // a fallback and is a no-op if the CloseWatcher already dismissed the overlay. useEffect(() => { if (isOpen && !visibleOverlays.includes(ref)) { visibleOverlays.push(ref); + + let watcher: {onclose: (() => void) | null, destroy: () => void} | null = null; + if (!isKeyboardDismissDisabled && supportsCloseWatcher()) { + watcher = new (globalThis as any).CloseWatcher(); + watcher!.onclose = () => { + onHideEvent(); + }; + } + return () => { let index = visibleOverlays.indexOf(ref); if (index >= 0) { visibleOverlays.splice(index, 1); } + watcher?.destroy(); }; } - }, [isOpen, ref]); - - // Only hide the overlay when it is the topmost visible overlay in the stack - let onHide = () => { - if (visibleOverlays[visibleOverlays.length - 1] === ref && onClose) { - onClose(); - } - }; + }, [isOpen, isKeyboardDismissDisabled, ref]); let onInteractOutsideStart = (e: PointerEvent) => { const topMostOverlay = visibleOverlays[visibleOverlays.length - 1]; diff --git a/packages/react-aria/test/overlays/useOverlay.test.js b/packages/react-aria/test/overlays/useOverlay.test.js index 58367686829..51434f4cd2b 100644 --- a/packages/react-aria/test/overlays/useOverlay.test.js +++ b/packages/react-aria/test/overlays/useOverlay.test.js @@ -128,4 +128,94 @@ describe('useOverlay', function () { fireEvent.keyDown(el, {key: 'Escape'}); expect(onClose).toHaveBeenCalledTimes(1); }); + + describe('CloseWatcher', function () { + let closeWatcherInstances; + let MockCloseWatcher; + + beforeEach(function () { + closeWatcherInstances = []; + MockCloseWatcher = class { + constructor() { + this.onclose = null; + closeWatcherInstances.push(this); + } + destroy() { + let index = closeWatcherInstances.indexOf(this); + if (index >= 0) { + closeWatcherInstances.splice(index, 1); + } + } + }; + globalThis.CloseWatcher = MockCloseWatcher; + }); + + afterEach(function () { + delete globalThis.CloseWatcher; + }); + + it('should use CloseWatcher to dismiss overlay when available', function () { + let onClose = jest.fn(); + render(); + expect(closeWatcherInstances.length).toBe(1); + closeWatcherInstances[0].onclose(); + expect(onClose).toHaveBeenCalledTimes(1); + }); + + it('should not create CloseWatcher when isKeyboardDismissDisabled is true', function () { + let onClose = jest.fn(); + render(); + expect(closeWatcherInstances.length).toBe(0); + }); + + it('should not create CloseWatcher when overlay is not open', function () { + let onClose = jest.fn(); + render(); + expect(closeWatcherInstances.length).toBe(0); + }); + + it('should destroy CloseWatcher when overlay unmounts', function () { + let onClose = jest.fn(); + let res = render(); + expect(closeWatcherInstances.length).toBe(1); + res.unmount(); + expect(closeWatcherInstances.length).toBe(0); + }); + + it('should dismiss only the top-most overlay with nested overlays', function () { + let onCloseOuter = jest.fn(); + let onCloseInner = jest.fn(); + render(); + render(); + + // Each overlay gets its own CloseWatcher + expect(closeWatcherInstances.length).toBe(2); + + // Browser fires close on the most recently created watcher (inner overlay) + closeWatcherInstances[1].onclose(); + expect(onCloseInner).toHaveBeenCalledTimes(1); + expect(onCloseOuter).not.toHaveBeenCalled(); + }); + + it('should dismiss inner then outer with per-overlay watchers', function () { + let onCloseOuter = jest.fn(); + let onCloseInner = jest.fn(); + render(); + let inner = render(); + + expect(closeWatcherInstances.length).toBe(2); + + // Dismiss inner overlay via its watcher + closeWatcherInstances[1].onclose(); + expect(onCloseInner).toHaveBeenCalledTimes(1); + + // Unmount inner - its watcher is destroyed + inner.unmount(); + expect(closeWatcherInstances.length).toBe(1); + + // Dismiss outer via its watcher + closeWatcherInstances[0].onclose(); + expect(onCloseOuter).toHaveBeenCalledTimes(1); + }); + }); }); From b73de825b36acfe08675c439d2fd9200acdd5943 Mon Sep 17 00:00:00 2001 From: Matt Van Horn <455140+mvanhorn@users.noreply.github.com> Date: Sun, 29 Mar 2026 23:04:03 -0700 Subject: [PATCH 2/2] fix: skip onKeyDown when CloseWatcher handles dismiss When CloseWatcher is supported, the keydown handler is now undefined to prevent double-dismiss in nested overlays. Previously, Escape in a submenu would fire both the CloseWatcher (closing the submenu) and the parent menu's onKeyDown handler (closing the parent), causing both to close at once. --- .../react-aria/src/overlays/useOverlay.ts | 7 ++-- .../test/overlays/useOverlay.test.js | 34 +++++++++++++++++++ 2 files changed, 39 insertions(+), 2 deletions(-) diff --git a/packages/react-aria/src/overlays/useOverlay.ts b/packages/react-aria/src/overlays/useOverlay.ts index 695865203f3..d8d605df94a 100644 --- a/packages/react-aria/src/overlays/useOverlay.ts +++ b/packages/react-aria/src/overlays/useOverlay.ts @@ -140,8 +140,11 @@ export function useOverlay(props: AriaOverlayProps, ref: RefObject { + // Handle the escape key — only used as a fallback when CloseWatcher is not supported. + // When CloseWatcher handles dismiss, the keydown handler is skipped entirely to avoid + // double-dismiss in nested overlays (e.g. submenus where Escape would close both the + // submenu via CloseWatcher and the parent menu via the bubbling keydown event). + let onKeyDown = supportsCloseWatcher() ? undefined : (e) => { if (e.key === 'Escape' && !isKeyboardDismissDisabled && !e.nativeEvent.isComposing) { e.stopPropagation(); e.preventDefault(); diff --git a/packages/react-aria/test/overlays/useOverlay.test.js b/packages/react-aria/test/overlays/useOverlay.test.js index 51434f4cd2b..878b5b99db2 100644 --- a/packages/react-aria/test/overlays/useOverlay.test.js +++ b/packages/react-aria/test/overlays/useOverlay.test.js @@ -197,6 +197,40 @@ describe('useOverlay', function () { expect(onCloseOuter).not.toHaveBeenCalled(); }); + it('should not attach onKeyDown when CloseWatcher is supported', function () { + let onClose = jest.fn(); + let res = render(); + let el = res.getByTestId('test'); + + // With CloseWatcher active, Escape keydown should not trigger onClose + // (the browser's CloseWatcher handles it instead) + fireEvent.keyDown(el, {key: 'Escape'}); + expect(onClose).not.toHaveBeenCalled(); + + // But CloseWatcher still works + closeWatcherInstances[0].onclose(); + expect(onClose).toHaveBeenCalledTimes(1); + }); + + it('should not double-dismiss nested overlays on Escape when CloseWatcher is active', function () { + let onCloseOuter = jest.fn(); + let onCloseInner = jest.fn(); + let outer = render(); + render(); + + let outerEl = outer.getByTestId('outer'); + + // Simulate browser behavior: CloseWatcher fires for inner overlay + closeWatcherInstances[1].onclose(); + expect(onCloseInner).toHaveBeenCalledTimes(1); + + // The Escape keydown event that triggered CloseWatcher also bubbles to the + // outer overlay's DOM. With the fix, onKeyDown is undefined so the outer + // overlay is NOT dismissed. + fireEvent.keyDown(outerEl, {key: 'Escape'}); + expect(onCloseOuter).not.toHaveBeenCalled(); + }); + it('should dismiss inner then outer with per-overlay watchers', function () { let onCloseOuter = jest.fn(); let onCloseInner = jest.fn();