From d7e20aec40479318dd1b72c1418b9d8eb385d0c9 Mon Sep 17 00:00:00 2001 From: Matt Van Horn <455140+mvanhorn@users.noreply.github.com> Date: Wed, 8 Jul 2026 12:22:09 -0700 Subject: [PATCH 1/7] feat: use CloseWatcher API for overlay dismiss in supported browsers Adds CloseWatcher-based dismissal for overlays (Escape + Android back) with per-overlay native watchers so nested overlays close innermost-first, matching native stacking. Falls back to the keyboard Escape shortcut only when CloseWatcher is unsupported, avoiding double-dismiss of parent overlays. --- .../react-aria/src/overlays/useOverlay.ts | 59 +++++-- .../test/overlays/useOverlay.test.js | 151 ++++++++++++++++++ 2 files changed, 198 insertions(+), 12 deletions(-) diff --git a/packages/react-aria/src/overlays/useOverlay.ts b/packages/react-aria/src/overlays/useOverlay.ts index 1f2f476f4f8..c50dc0c342c 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'; import {useKeyboard} from '../interactions/useKeyboard'; @@ -60,6 +61,15 @@ export interface OverlayAria { const visibleOverlays: RefObject[] = []; +interface CloseWatcher { + onclose: (() => void) | null, + destroy: () => void +} + +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, @@ -77,25 +87,47 @@ export function useOverlay(props: AriaOverlayProps, ref: RefObject>(undefined); + let onHide = () => { + onClose?.(); + }; + + // Only hide the overlay when it is the topmost visible overlay in the stack. + let onHideTopmost = () => { + if (visibleOverlays[visibleOverlays.length - 1] === ref && onClose) { + onHide(); + } + }; + + // Stable callback for CloseWatcher that always calls the latest onHideTopmost. + // useEffectEvent returns a stable reference, so the watcher doesn't need + // to be recreated when onClose changes. + let onHideTopmostEvent = useEffectEvent(onHideTopmost); + // 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 so the browser's + // native close watcher stack handles nested overlay ordering for Escape and Android back. useEffect(() => { if (isOpen && !visibleOverlays.includes(ref)) { visibleOverlays.push(ref); + + let watcher: CloseWatcher | null = null; + if (!isKeyboardDismissDisabled && supportsCloseWatcher()) { + let closeWatcher: CloseWatcher = new (globalThis as any).CloseWatcher(); + closeWatcher.onclose = () => { + onHideTopmostEvent(); + }; + watcher = closeWatcher; + } + 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]; @@ -119,18 +151,21 @@ export function useOverlay(props: AriaOverlayProps, ref: RefObject { - if (!isKeyboardDismissDisabled) { - onHide(); + if (!supportsCloseWatcher() && !isKeyboardDismissDisabled) { + onHideTopmost(); return; } return false; diff --git a/packages/react-aria/test/overlays/useOverlay.test.js b/packages/react-aria/test/overlays/useOverlay.test.js index 0ba68c5d2ee..c97de90bfd9 100644 --- a/packages/react-aria/test/overlays/useOverlay.test.js +++ b/packages/react-aria/test/overlays/useOverlay.test.js @@ -11,6 +11,7 @@ */ import { + act, fireEvent, installMouseEvent, installPointerEvent, @@ -138,4 +139,154 @@ 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(); + + expect(closeWatcherInstances.length).toBe(2); + + // The browser's native CloseWatcher stack closes the most recently created watcher first. + closeWatcherInstances[1].onclose(); + expect(onCloseInner).toHaveBeenCalledTimes(1); + expect(onCloseOuter).not.toHaveBeenCalled(); + }); + + it('should dismiss the most recently opened overlay even when focus is in an older overlay', function () { + let onCloseFirst = jest.fn(); + let onCloseSecond = jest.fn(); + let first = render( + + + + ); + render( + + + + ); + + expect(closeWatcherInstances.length).toBe(2); + let firstInput = first.getByTestId('first-input'); + act(() => { + firstInput.focus(); + }); + expect(document.activeElement).toBe(firstInput); + + closeWatcherInstances[1].onclose(); + expect(onCloseSecond).toHaveBeenCalledTimes(1); + expect(onCloseFirst).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 native watcher stack', 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); + expect(closeWatcherInstances.length).toBe(2); + + // Unmount inner - the outer watcher remains. + inner.unmount(); + expect(closeWatcherInstances.length).toBe(1); + + // Dismiss outer via its watcher + closeWatcherInstances[0].onclose(); + expect(onCloseOuter).toHaveBeenCalledTimes(1); + }); + }); }); From 310945a28669360b50d2bd93d07e6d4f0a5e0b99 Mon Sep 17 00:00:00 2001 From: Matt Van Horn <455140+mvanhorn@users.noreply.github.com> Date: Thu, 9 Jul 2026 08:24:19 -0700 Subject: [PATCH 2/7] fix: dispatch CloseWatcher cancel/close to the top-most overlay --- .../react-aria/src/overlays/useOverlay.ts | 62 +++++++++++++---- .../test/overlays/useOverlay.test.js | 66 ++++++++++++++----- 2 files changed, 96 insertions(+), 32 deletions(-) diff --git a/packages/react-aria/src/overlays/useOverlay.ts b/packages/react-aria/src/overlays/useOverlay.ts index c50dc0c342c..2e15a1b9c01 100644 --- a/packages/react-aria/src/overlays/useOverlay.ts +++ b/packages/react-aria/src/overlays/useOverlay.ts @@ -59,17 +59,28 @@ export interface OverlayAria { underlayProps: DOMAttributes; } +interface VisibleOverlayData { + onClose: () => void; + isKeyboardDismissDisabled: boolean; +} + const visibleOverlays: RefObject[] = []; +const visibleOverlayData = new Map, VisibleOverlayData>(); interface CloseWatcher { - onclose: (() => void) | null, - destroy: () => void + oncancel: ((event: Event) => void) | null; + onclose: (() => void) | null; + destroy: () => void; } function supportsCloseWatcher(): boolean { return typeof globalThis.CloseWatcher !== 'undefined'; } +function getTopMostOverlay(): RefObject | undefined { + return visibleOverlays[visibleOverlays.length - 1]; +} + /** * 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, @@ -85,15 +96,17 @@ export function useOverlay(props: AriaOverlayProps, ref: RefObject>(undefined); + let lastVisibleOverlay = useRef | undefined>(undefined); let onHide = () => { onClose?.(); }; + let onHideEvent = useEffectEvent(onHide); + // Only hide the overlay when it is the topmost visible overlay in the stack. let onHideTopmost = () => { - if (visibleOverlays[visibleOverlays.length - 1] === ref && onClose) { + if (getTopMostOverlay() === ref && onClose) { onHide(); } }; @@ -109,10 +122,19 @@ export function useOverlay(props: AriaOverlayProps, ref: RefObject { if (isOpen && !visibleOverlays.includes(ref)) { visibleOverlays.push(ref); + visibleOverlayData.set(ref, { + onClose: () => onHideEvent(), + isKeyboardDismissDisabled + }); let watcher: CloseWatcher | null = null; if (!isKeyboardDismissDisabled && supportsCloseWatcher()) { let closeWatcher: CloseWatcher = new (globalThis as any).CloseWatcher(); + closeWatcher.oncancel = event => { + if (getTopMostOverlay() !== ref) { + event.preventDefault(); + } + }; closeWatcher.onclose = () => { onHideTopmostEvent(); }; @@ -124,13 +146,14 @@ export function useOverlay(props: AriaOverlayProps, ref: RefObject= 0) { visibleOverlays.splice(index, 1); } + visibleOverlayData.delete(ref); watcher?.destroy(); }; } }, [isOpen, isKeyboardDismissDisabled, ref]); let onInteractOutsideStart = (e: PointerEvent) => { - const topMostOverlay = visibleOverlays[visibleOverlays.length - 1]; + const topMostOverlay = getTopMostOverlay(); lastVisibleOverlay.current = topMostOverlay; if ( !shouldCloseOnInteractOutside || @@ -147,7 +170,7 @@ export function useOverlay(props: AriaOverlayProps, ref: RefObject { - if (!supportsCloseWatcher() && !isKeyboardDismissDisabled) { - onHideTopmost(); - return; + let topMostOverlay = getTopMostOverlay(); + if (!topMostOverlay) { + return false; } - return false; + + let topMostOverlayData = visibleOverlayData.get(topMostOverlay); + if (!topMostOverlayData) { + return false; + } + + if (topMostOverlayData.isKeyboardDismissDisabled) { + if (topMostOverlay !== ref) { + return; + } + return false; + } + + topMostOverlayData.onClose(); + return; } } }); diff --git a/packages/react-aria/test/overlays/useOverlay.test.js b/packages/react-aria/test/overlays/useOverlay.test.js index c97de90bfd9..4aec5666a57 100644 --- a/packages/react-aria/test/overlays/useOverlay.test.js +++ b/packages/react-aria/test/overlays/useOverlay.test.js @@ -148,6 +148,7 @@ describe('useOverlay', function () { closeWatcherInstances = []; MockCloseWatcher = class { constructor() { + this.oncancel = null; this.onclose = null; closeWatcherInstances.push(this); } @@ -233,40 +234,69 @@ describe('useOverlay', function () { expect(onCloseFirst).not.toHaveBeenCalled(); }); - it('should not attach onKeyDown when CloseWatcher is supported', function () { + it('should dismiss on Escape 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 () { + it('should dismiss the most recently opened overlay on Escape when focus is in an older overlay', function () { + let onCloseFirst = jest.fn(); + let onCloseSecond = jest.fn(); + let first = render( + + + + ); + render( + + + + ); + + expect(closeWatcherInstances.length).toBe(2); + let firstInput = first.getByTestId('first-input'); + act(() => { + firstInput.focus(); + }); + expect(document.activeElement).toBe(firstInput); + + fireEvent.keyDown(firstInput, {key: 'Escape'}); + expect(onCloseSecond).toHaveBeenCalledTimes(1); + expect(onCloseFirst).not.toHaveBeenCalled(); + }); + + it('should only dismiss the top-most nested overlay on Escape when CloseWatcher is active', function () { let onCloseOuter = jest.fn(); let onCloseInner = jest.fn(); - let outer = render(); - render(); + render(); + let inner = render(); - let outerEl = outer.getByTestId('outer'); + let innerEl = inner.getByTestId('inner'); - // Simulate browser behavior: CloseWatcher fires for inner overlay - closeWatcherInstances[1].onclose(); + fireEvent.keyDown(innerEl, {key: 'Escape'}); 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 cancel non-topmost CloseWatcher close requests when possible', function () { + let onCloseOuter = jest.fn(); + let onCloseInner = jest.fn(); + render(); + render(); + + let outerCancelEvent = new Event('cancel', {cancelable: true}); + closeWatcherInstances[0].oncancel(outerCancelEvent); + expect(outerCancelEvent.defaultPrevented).toBe(true); + + let innerCancelEvent = new Event('cancel', {cancelable: true}); + closeWatcherInstances[1].oncancel(innerCancelEvent); + expect(innerCancelEvent.defaultPrevented).toBe(false); + }); + it('should dismiss inner then outer with native watcher stack', function () { let onCloseOuter = jest.fn(); let onCloseInner = jest.fn(); From e9c61f4b0b13a6b955e1ba09a9bfc173836e41eb Mon Sep 17 00:00:00 2001 From: Matt Van Horn <455140+mvanhorn@users.noreply.github.com> Date: Thu, 9 Jul 2026 21:58:10 -0700 Subject: [PATCH 3/7] fix: address CloseWatcher test failures in useOverlay Co-Authored-By: Claude Fable 5 --- .../react-aria/src/overlays/useOverlay.ts | 114 ++++++++++++++---- .../test/overlays/useOverlay.test.js | 81 ++++++++++++- 2 files changed, 166 insertions(+), 29 deletions(-) diff --git a/packages/react-aria/src/overlays/useOverlay.ts b/packages/react-aria/src/overlays/useOverlay.ts index 2e15a1b9c01..bdc19655bac 100644 --- a/packages/react-aria/src/overlays/useOverlay.ts +++ b/packages/react-aria/src/overlays/useOverlay.ts @@ -64,8 +64,12 @@ interface VisibleOverlayData { isKeyboardDismissDisabled: boolean; } -const visibleOverlays: RefObject[] = []; -const visibleOverlayData = new Map, VisibleOverlayData>(); +interface VisibleOverlay { + ref: RefObject; + data: VisibleOverlayData; +} + +const visibleOverlays: VisibleOverlay[] = []; interface CloseWatcher { oncancel: ((event: Event) => void) | null; @@ -77,8 +81,43 @@ function supportsCloseWatcher(): boolean { return typeof globalThis.CloseWatcher !== 'undefined'; } -function getTopMostOverlay(): RefObject | undefined { - return visibleOverlays[visibleOverlays.length - 1]; +function getTopMostOverlay(): VisibleOverlay | undefined { + let topMostOverlay: VisibleOverlay | undefined; + for (let overlay of visibleOverlays) { + let element = overlay.ref.current; + if (!element) { + continue; + } + + if (!topMostOverlay?.ref.current) { + topMostOverlay = overlay; + continue; + } + + let topMostElement = topMostOverlay.ref.current; + if (topMostElement === element) { + topMostOverlay = overlay; + continue; + } + + let ownerNode = element.ownerDocument.defaultView?.Node; + let position = topMostElement.compareDocumentPosition(element); + if ( + ownerNode && + !(position & ownerNode.DOCUMENT_POSITION_DISCONNECTED) && + position & ownerNode.DOCUMENT_POSITION_FOLLOWING + ) { + topMostOverlay = overlay; + } + } + + return topMostOverlay ?? visibleOverlays[visibleOverlays.length - 1]; +} + +function isOnlyVisibleOverlayForRef(overlay: VisibleOverlay): boolean { + return !visibleOverlays.some( + visibleOverlay => visibleOverlay !== overlay && visibleOverlay.ref === overlay.ref + ); } /** @@ -96,7 +135,8 @@ export function useOverlay(props: AriaOverlayProps, ref: RefObject | undefined>(undefined); + let lastVisibleOverlay = useRef(undefined); + let visibleOverlay = useRef(null); let onHide = () => { onClose?.(); @@ -106,7 +146,7 @@ export function useOverlay(props: AriaOverlayProps, ref: RefObject { - if (getTopMostOverlay() === ref && onClose) { + if (getTopMostOverlay() === visibleOverlay.current && onClose) { onHide(); } }; @@ -120,18 +160,22 @@ export function useOverlay(props: AriaOverlayProps, ref: RefObject { - if (isOpen && !visibleOverlays.includes(ref)) { - visibleOverlays.push(ref); - visibleOverlayData.set(ref, { - onClose: () => onHideEvent(), - isKeyboardDismissDisabled - }); + if (isOpen && !visibleOverlay.current) { + let overlay: VisibleOverlay = { + ref, + data: { + onClose: () => onHideEvent(), + isKeyboardDismissDisabled + } + }; + visibleOverlay.current = overlay; + visibleOverlays.push(overlay); let watcher: CloseWatcher | null = null; if (!isKeyboardDismissDisabled && supportsCloseWatcher()) { let closeWatcher: CloseWatcher = new (globalThis as any).CloseWatcher(); closeWatcher.oncancel = event => { - if (getTopMostOverlay() !== ref) { + if (getTopMostOverlay() !== overlay) { event.preventDefault(); } }; @@ -142,11 +186,11 @@ export function useOverlay(props: AriaOverlayProps, ref: RefObject { - let index = visibleOverlays.indexOf(ref); + let index = visibleOverlays.indexOf(overlay); if (index >= 0) { visibleOverlays.splice(index, 1); } - visibleOverlayData.delete(ref); + visibleOverlay.current = null; watcher?.destroy(); }; } @@ -159,7 +203,11 @@ export function useOverlay(props: AriaOverlayProps, ref: RefObject { + Escape: e => { + if (e.nativeEvent.cancelBubble) { + return false; + } + + if (supportsCloseWatcher()) { + return false; + } + let topMostOverlay = getTopMostOverlay(); if (!topMostOverlay) { return false; } - let topMostOverlayData = visibleOverlayData.get(topMostOverlay); - if (!topMostOverlayData) { + let overlay = topMostOverlay.ref === ref ? visibleOverlay.current : topMostOverlay; + if (!overlay) { return false; } - if (topMostOverlayData.isKeyboardDismissDisabled) { - if (topMostOverlay !== ref) { + if (overlay.data.isKeyboardDismissDisabled) { + if (overlay !== visibleOverlay.current) { return; } return false; } - topMostOverlayData.onClose(); + overlay.data.onClose(); return; } } diff --git a/packages/react-aria/test/overlays/useOverlay.test.js b/packages/react-aria/test/overlays/useOverlay.test.js index 4aec5666a57..a84757e181a 100644 --- a/packages/react-aria/test/overlays/useOverlay.test.js +++ b/packages/react-aria/test/overlays/useOverlay.test.js @@ -26,13 +26,27 @@ function Example(props) { let {overlayProps, underlayProps} = useOverlay(props, ref); return (
-
+
{props.children}
); } +function SharedRefExample(props) { + let ref = useRef(); + let first = useOverlay({isOpen: true, onClose: props.onCloseFirst}, ref); + let second = useOverlay({isOpen: true, onClose: props.onCloseSecond}, ref); + return ( +
+
+
+ ); +} + describe('useOverlay', function () { describe.each` type | prepare | actions @@ -140,6 +154,48 @@ describe('useOverlay', function () { expect(onClose).toHaveBeenCalledTimes(1); }); + it('should not hide the overlay when an earlier Escape handler stops propagation', function () { + let onClose = jest.fn(); + let res = render( + e.stopPropagation() + }} + /> + ); + let el = res.getByTestId('test'); + fireEvent.keyDown(el, {key: 'Escape'}); + expect(onClose).not.toHaveBeenCalled(); + }); + + it('should hide the innermost overlay when nested overlays mount together', function () { + let onCloseOuter = jest.fn(); + let onCloseInner = jest.fn(); + let res = render( + + + + ); + + fireEvent.keyDown(res.getByTestId('inner'), {key: 'Escape'}); + expect(onCloseInner).toHaveBeenCalledTimes(1); + expect(onCloseOuter).not.toHaveBeenCalled(); + }); + + it('should keep separate close handlers for overlays using the same ref', function () { + let onCloseFirst = jest.fn(); + let onCloseSecond = jest.fn(); + let res = render( + + ); + + fireEvent.keyDown(res.getByTestId('test'), {key: 'Escape'}); + expect(onCloseSecond).toHaveBeenCalledTimes(1); + expect(onCloseFirst).not.toHaveBeenCalled(); + }); + describe('CloseWatcher', function () { let closeWatcherInstances; let MockCloseWatcher; @@ -234,16 +290,25 @@ describe('useOverlay', function () { expect(onCloseFirst).not.toHaveBeenCalled(); }); - it('should dismiss on Escape when CloseWatcher is supported', function () { + it('should let CloseWatcher handle Escape when supported', function () { let onClose = jest.fn(); - let res = render(); + let onKeyDown = jest.fn(); + let res = render( +
+ +
+ ); let el = res.getByTestId('test'); fireEvent.keyDown(el, {key: 'Escape'}); + expect(onKeyDown).toHaveBeenCalledTimes(1); + expect(onClose).not.toHaveBeenCalled(); + + closeWatcherInstances[0].onclose(); expect(onClose).toHaveBeenCalledTimes(1); }); - it('should dismiss the most recently opened overlay on Escape when focus is in an older overlay', function () { + it('should let native CloseWatcher dismiss the most recently opened overlay', function () { let onCloseFirst = jest.fn(); let onCloseSecond = jest.fn(); let first = render( @@ -265,6 +330,10 @@ describe('useOverlay', function () { expect(document.activeElement).toBe(firstInput); fireEvent.keyDown(firstInput, {key: 'Escape'}); + expect(onCloseSecond).not.toHaveBeenCalled(); + expect(onCloseFirst).not.toHaveBeenCalled(); + + closeWatcherInstances[1].onclose(); expect(onCloseSecond).toHaveBeenCalledTimes(1); expect(onCloseFirst).not.toHaveBeenCalled(); }); @@ -278,6 +347,10 @@ describe('useOverlay', function () { let innerEl = inner.getByTestId('inner'); fireEvent.keyDown(innerEl, {key: 'Escape'}); + expect(onCloseInner).not.toHaveBeenCalled(); + expect(onCloseOuter).not.toHaveBeenCalled(); + + closeWatcherInstances[1].onclose(); expect(onCloseInner).toHaveBeenCalledTimes(1); expect(onCloseOuter).not.toHaveBeenCalled(); }); From 86a16834026d37f8a897ca2f7dd1da13ec7ab61f Mon Sep 17 00:00:00 2001 From: Matt Van Horn <455140+mvanhorn@users.noreply.github.com> Date: Sat, 11 Jul 2026 12:28:05 -0700 Subject: [PATCH 4/7] fix: rely on native CloseWatcher stack for overlay dismissal ordering Drop the oncancel/topmost-guard bookkeeping and let the browser's CloseWatcher stack decide which watcher's onclose fires; update the test to assert the native-stack behavior. --- .../react-aria/src/overlays/useOverlay.ts | 20 +------------------ .../test/overlays/useOverlay.test.js | 13 ++++-------- 2 files changed, 5 insertions(+), 28 deletions(-) diff --git a/packages/react-aria/src/overlays/useOverlay.ts b/packages/react-aria/src/overlays/useOverlay.ts index bdc19655bac..9c580e942e1 100644 --- a/packages/react-aria/src/overlays/useOverlay.ts +++ b/packages/react-aria/src/overlays/useOverlay.ts @@ -72,7 +72,6 @@ interface VisibleOverlay { const visibleOverlays: VisibleOverlay[] = []; interface CloseWatcher { - oncancel: ((event: Event) => void) | null; onclose: (() => void) | null; destroy: () => void; } @@ -144,18 +143,6 @@ export function useOverlay(props: AriaOverlayProps, ref: RefObject { - if (getTopMostOverlay() === visibleOverlay.current && onClose) { - onHide(); - } - }; - - // Stable callback for CloseWatcher that always calls the latest onHideTopmost. - // useEffectEvent returns a stable reference, so the watcher doesn't need - // to be recreated when onClose changes. - let onHideTopmostEvent = useEffectEvent(onHideTopmost); - // 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 so the browser's // native close watcher stack handles nested overlay ordering for Escape and Android back. @@ -174,13 +161,8 @@ export function useOverlay(props: AriaOverlayProps, ref: RefObject { - if (getTopMostOverlay() !== overlay) { - event.preventDefault(); - } - }; closeWatcher.onclose = () => { - onHideTopmostEvent(); + onHideEvent(); }; watcher = closeWatcher; } diff --git a/packages/react-aria/test/overlays/useOverlay.test.js b/packages/react-aria/test/overlays/useOverlay.test.js index a84757e181a..b547f08e1f6 100644 --- a/packages/react-aria/test/overlays/useOverlay.test.js +++ b/packages/react-aria/test/overlays/useOverlay.test.js @@ -204,7 +204,6 @@ describe('useOverlay', function () { closeWatcherInstances = []; MockCloseWatcher = class { constructor() { - this.oncancel = null; this.onclose = null; closeWatcherInstances.push(this); } @@ -355,19 +354,15 @@ describe('useOverlay', function () { expect(onCloseOuter).not.toHaveBeenCalled(); }); - it('should cancel non-topmost CloseWatcher close requests when possible', function () { + it('should let the browser determine which CloseWatcher closes', function () { let onCloseOuter = jest.fn(); let onCloseInner = jest.fn(); render(); render(); - let outerCancelEvent = new Event('cancel', {cancelable: true}); - closeWatcherInstances[0].oncancel(outerCancelEvent); - expect(outerCancelEvent.defaultPrevented).toBe(true); - - let innerCancelEvent = new Event('cancel', {cancelable: true}); - closeWatcherInstances[1].oncancel(innerCancelEvent); - expect(innerCancelEvent.defaultPrevented).toBe(false); + closeWatcherInstances[0].onclose(); + expect(onCloseOuter).toHaveBeenCalledTimes(1); + expect(onCloseInner).not.toHaveBeenCalled(); }); it('should dismiss inner then outer with native watcher stack', function () { From faf8b6533b42ac76dda0081f469550fb515412ae Mon Sep 17 00:00:00 2001 From: Matt Van Horn <455140+mvanhorn@users.noreply.github.com> Date: Fri, 17 Jul 2026 11:16:23 -0700 Subject: [PATCH 5/7] fix(overlays): skip the escape keydown handler when CloseWatcher is supported Gate shortcut registration on supportsCloseWatcher() instead of checking inside the handler, so no handler is attached in supported browsers. Rework useOverlay tests to a single render with defaultIsOpen and fix the instance-lifecycle model. --- .../react-aria/src/overlays/useOverlay.ts | 6 +- .../test/overlays/useOverlay.test.js | 64 +++++++++++-------- 2 files changed, 39 insertions(+), 31 deletions(-) diff --git a/packages/react-aria/src/overlays/useOverlay.ts b/packages/react-aria/src/overlays/useOverlay.ts index 9c580e942e1..162d4e36dda 100644 --- a/packages/react-aria/src/overlays/useOverlay.ts +++ b/packages/react-aria/src/overlays/useOverlay.ts @@ -220,16 +220,12 @@ export function useOverlay(props: AriaOverlayProps, ref: RefObject { if (e.nativeEvent.cancelBubble) { return false; } - if (supportsCloseWatcher()) { - return false; - } - let topMostOverlay = getTopMostOverlay(); if (!topMostOverlay) { return false; diff --git a/packages/react-aria/test/overlays/useOverlay.test.js b/packages/react-aria/test/overlays/useOverlay.test.js index b547f08e1f6..b189ff9b660 100644 --- a/packages/react-aria/test/overlays/useOverlay.test.js +++ b/packages/react-aria/test/overlays/useOverlay.test.js @@ -18,7 +18,7 @@ import { render } from '@react-spectrum/test-utils-internal'; import {mergeProps} from '../../src/utils/mergeProps'; -import React, {useRef} from 'react'; +import React, {useRef, useState} from 'react'; import {useOverlay} from '../../src/overlays/useOverlay'; function Example(props) { @@ -36,6 +36,19 @@ function Example(props) { ); } +function StatefulExample({defaultIsOpen, onClose, ...props}) { + let [isOpen, setIsOpen] = useState(defaultIsOpen); + return ( + { + setIsOpen(false); + onClose?.(); + }} /> + ); +} + function SharedRefExample(props) { let ref = useRef(); let first = useOverlay({isOpen: true, onClose: props.onCloseFirst}, ref); @@ -213,6 +226,9 @@ describe('useOverlay', function () { closeWatcherInstances.splice(index, 1); } } + static closeTopMost() { + closeWatcherInstances[closeWatcherInstances.length - 1]?.onclose(); + } }; globalThis.CloseWatcher = MockCloseWatcher; }); @@ -340,51 +356,47 @@ describe('useOverlay', function () { it('should only dismiss the top-most nested overlay on Escape when CloseWatcher is active', function () { let onCloseOuter = jest.fn(); let onCloseInner = jest.fn(); - render(); - let inner = render(); + let res = render( + <> + + + + ); - let innerEl = inner.getByTestId('inner'); + let innerEl = res.getByTestId('inner'); fireEvent.keyDown(innerEl, {key: 'Escape'}); expect(onCloseInner).not.toHaveBeenCalled(); expect(onCloseOuter).not.toHaveBeenCalled(); - closeWatcherInstances[1].onclose(); + act(() => MockCloseWatcher.closeTopMost()); expect(onCloseInner).toHaveBeenCalledTimes(1); expect(onCloseOuter).not.toHaveBeenCalled(); - }); - - it('should let the browser determine which CloseWatcher closes', function () { - let onCloseOuter = jest.fn(); - let onCloseInner = jest.fn(); - render(); - render(); - - closeWatcherInstances[0].onclose(); - expect(onCloseOuter).toHaveBeenCalledTimes(1); - expect(onCloseInner).not.toHaveBeenCalled(); + expect(res.queryByTestId('inner')).toBeNull(); + expect(closeWatcherInstances).toHaveLength(1); }); it('should dismiss inner then outer with native watcher stack', function () { let onCloseOuter = jest.fn(); let onCloseInner = jest.fn(); - render(); - let inner = render(); + let res = render( + <> + + + + ); expect(closeWatcherInstances.length).toBe(2); - // Dismiss inner overlay via its watcher - closeWatcherInstances[1].onclose(); + act(() => MockCloseWatcher.closeTopMost()); expect(onCloseInner).toHaveBeenCalledTimes(1); - expect(closeWatcherInstances.length).toBe(2); - - // Unmount inner - the outer watcher remains. - inner.unmount(); + expect(res.queryByTestId('inner')).toBeNull(); expect(closeWatcherInstances.length).toBe(1); - // Dismiss outer via its watcher - closeWatcherInstances[0].onclose(); + act(() => MockCloseWatcher.closeTopMost()); expect(onCloseOuter).toHaveBeenCalledTimes(1); + expect(res.queryByTestId('outer')).toBeNull(); + expect(closeWatcherInstances.length).toBe(0); }); }); }); From 535825e4bdb5f7a62265ed01d87ee4c73322d3a2 Mon Sep 17 00:00:00 2001 From: Matt Van Horn <455140+mvanhorn@users.noreply.github.com> Date: Sun, 26 Jul 2026 22:52:50 -0700 Subject: [PATCH 6/7] style: run oxfmt on the touched overlay files yarn format:check was failing on both files this PR touches. Formatting only -- git diff -w shows no semantic change. --- .../react-aria/src/overlays/useOverlay.ts | 50 ++++++++++--------- .../test/overlays/useOverlay.test.js | 3 +- 2 files changed, 28 insertions(+), 25 deletions(-) diff --git a/packages/react-aria/src/overlays/useOverlay.ts b/packages/react-aria/src/overlays/useOverlay.ts index 162d4e36dda..8673d54b963 100644 --- a/packages/react-aria/src/overlays/useOverlay.ts +++ b/packages/react-aria/src/overlays/useOverlay.ts @@ -220,33 +220,35 @@ export function useOverlay(props: AriaOverlayProps, ref: RefObject { - if (e.nativeEvent.cancelBubble) { - return false; - } - - let topMostOverlay = getTopMostOverlay(); - if (!topMostOverlay) { - return false; - } - - let overlay = topMostOverlay.ref === ref ? visibleOverlay.current : topMostOverlay; - if (!overlay) { - return false; - } - - if (overlay.data.isKeyboardDismissDisabled) { - if (overlay !== visibleOverlay.current) { + shortcuts: supportsCloseWatcher() + ? undefined + : { + Escape: e => { + if (e.nativeEvent.cancelBubble) { + return false; + } + + let topMostOverlay = getTopMostOverlay(); + if (!topMostOverlay) { + return false; + } + + let overlay = topMostOverlay.ref === ref ? visibleOverlay.current : topMostOverlay; + if (!overlay) { + return false; + } + + if (overlay.data.isKeyboardDismissDisabled) { + if (overlay !== visibleOverlay.current) { + return; + } + return false; + } + + overlay.data.onClose(); return; } - return false; } - - overlay.data.onClose(); - return; - } - } }); // Handle clicking outside the overlay to close it diff --git a/packages/react-aria/test/overlays/useOverlay.test.js b/packages/react-aria/test/overlays/useOverlay.test.js index b189ff9b660..96620a686d2 100644 --- a/packages/react-aria/test/overlays/useOverlay.test.js +++ b/packages/react-aria/test/overlays/useOverlay.test.js @@ -45,7 +45,8 @@ function StatefulExample({defaultIsOpen, onClose, ...props}) { onClose={() => { setIsOpen(false); onClose?.(); - }} /> + }} + /> ); } From c7dd1cac5267cc19431a631a62534bc21c849898 Mon Sep 17 00:00:00 2001 From: Matt Van Horn <455140+mvanhorn@users.noreply.github.com> Date: Sun, 26 Jul 2026 23:00:36 -0700 Subject: [PATCH 7/7] test: unmount the stateful overlay harness on close The two nested-dismiss CloseWatcher tests asserted the closed overlay had left the DOM, but StatefulExample kept rendering Example, and Example renders its node unconditionally -- isOpen only feeds useOverlay. So the element could never disappear and the assertion could never pass, no matter what the implementation did. Return null once closed, the way a real consumer unmounts an overlay. That also tears down the overlay's CloseWatcher, which is what the closeWatcherInstances assertions in those tests are checking. No production change: useOverlay is untouched. --- packages/react-aria/test/overlays/useOverlay.test.js | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/packages/react-aria/test/overlays/useOverlay.test.js b/packages/react-aria/test/overlays/useOverlay.test.js index 96620a686d2..69ad8f42556 100644 --- a/packages/react-aria/test/overlays/useOverlay.test.js +++ b/packages/react-aria/test/overlays/useOverlay.test.js @@ -38,6 +38,13 @@ function Example(props) { function StatefulExample({defaultIsOpen, onClose, ...props}) { let [isOpen, setIsOpen] = useState(defaultIsOpen); + // Unmount on close the way a real consumer does. Example renders its node + // unconditionally, so leaving it mounted would keep both the element and the + // overlay's CloseWatcher alive and make the nested-dismiss assertions + // unobservable. + if (!isOpen) { + return null; + } return (