Skip to content

Commit 4e9cd71

Browse files
mvanhornclaude
andcommitted
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 <noreply@anthropic.com>
1 parent 6a0d13c commit 4e9cd71

File tree

2 files changed

+122
-8
lines changed

2 files changed

+122
-8
lines changed

packages/react-aria/src/overlays/useOverlay.ts

Lines changed: 32 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import {DOMAttributes, RefObject} from '@react-types/shared';
1414
import {getEventTarget} from '../utils/shadowdom/DOMFunctions';
1515
import {isElementInChildOfActiveScope} from '../focus/FocusScope';
1616
import {useEffect, useRef} from 'react';
17+
import {useEffectEvent} from '../utils/useEffectEvent';
1718
import {useFocusWithin} from '../interactions/useFocusWithin';
1819
import {useInteractOutside} from '../interactions/useInteractOutside';
1920

@@ -57,6 +58,10 @@ export interface OverlayAria {
5758

5859
const visibleOverlays: RefObject<Element | null>[] = [];
5960

61+
function supportsCloseWatcher(): boolean {
62+
return typeof globalThis.CloseWatcher !== 'undefined';
63+
}
64+
6065
/**
6166
* Provides the behavior for overlays such as dialogs, popovers, and menus.
6267
* 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<Element | nul
7479

7580
let lastVisibleOverlay = useRef<RefObject<Element | null>>(undefined);
7681

82+
// Only hide the overlay when it is the topmost visible overlay in the stack
83+
let onHide = () => {
84+
if (visibleOverlays[visibleOverlays.length - 1] === ref && onClose) {
85+
onClose();
86+
}
87+
};
88+
89+
// Stable callback for CloseWatcher that always calls the latest onHide.
90+
// useEffectEvent returns a stable reference, so the watcher doesn't need
91+
// to be recreated when onClose changes.
92+
let onHideEvent = useEffectEvent(onHide);
93+
7794
// Add the overlay ref to the stack of visible overlays on mount, and remove on unmount.
95+
// When CloseWatcher is supported, each overlay gets its own instance. The browser
96+
// internally stacks watchers so Escape dismisses the most recently created one first,
97+
// which also handles the Android back button. The onKeyDown handler below is kept as
98+
// a fallback and is a no-op if the CloseWatcher already dismissed the overlay.
7899
useEffect(() => {
79100
if (isOpen && !visibleOverlays.includes(ref)) {
80101
visibleOverlays.push(ref);
102+
103+
let watcher: {onclose: (() => void) | null, destroy: () => void} | null = null;
104+
if (!isKeyboardDismissDisabled && supportsCloseWatcher()) {
105+
watcher = new (globalThis as any).CloseWatcher();
106+
watcher!.onclose = () => {
107+
onHideEvent();
108+
};
109+
}
110+
81111
return () => {
82112
let index = visibleOverlays.indexOf(ref);
83113
if (index >= 0) {
84114
visibleOverlays.splice(index, 1);
85115
}
116+
watcher?.destroy();
86117
};
87118
}
88-
}, [isOpen, ref]);
89-
90-
// Only hide the overlay when it is the topmost visible overlay in the stack
91-
let onHide = () => {
92-
if (visibleOverlays[visibleOverlays.length - 1] === ref && onClose) {
93-
onClose();
94-
}
95-
};
119+
}, [isOpen, isKeyboardDismissDisabled, ref]);
96120

97121
let onInteractOutsideStart = (e: PointerEvent) => {
98122
const topMostOverlay = visibleOverlays[visibleOverlays.length - 1];

packages/react-aria/test/overlays/useOverlay.test.js

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -128,4 +128,94 @@ describe('useOverlay', function () {
128128
fireEvent.keyDown(el, {key: 'Escape'});
129129
expect(onClose).toHaveBeenCalledTimes(1);
130130
});
131+
132+
describe('CloseWatcher', function () {
133+
let closeWatcherInstances;
134+
let MockCloseWatcher;
135+
136+
beforeEach(function () {
137+
closeWatcherInstances = [];
138+
MockCloseWatcher = class {
139+
constructor() {
140+
this.onclose = null;
141+
closeWatcherInstances.push(this);
142+
}
143+
destroy() {
144+
let index = closeWatcherInstances.indexOf(this);
145+
if (index >= 0) {
146+
closeWatcherInstances.splice(index, 1);
147+
}
148+
}
149+
};
150+
globalThis.CloseWatcher = MockCloseWatcher;
151+
});
152+
153+
afterEach(function () {
154+
delete globalThis.CloseWatcher;
155+
});
156+
157+
it('should use CloseWatcher to dismiss overlay when available', function () {
158+
let onClose = jest.fn();
159+
render(<Example isOpen onClose={onClose} />);
160+
expect(closeWatcherInstances.length).toBe(1);
161+
closeWatcherInstances[0].onclose();
162+
expect(onClose).toHaveBeenCalledTimes(1);
163+
});
164+
165+
it('should not create CloseWatcher when isKeyboardDismissDisabled is true', function () {
166+
let onClose = jest.fn();
167+
render(<Example isOpen onClose={onClose} isKeyboardDismissDisabled />);
168+
expect(closeWatcherInstances.length).toBe(0);
169+
});
170+
171+
it('should not create CloseWatcher when overlay is not open', function () {
172+
let onClose = jest.fn();
173+
render(<Example isOpen={false} onClose={onClose} />);
174+
expect(closeWatcherInstances.length).toBe(0);
175+
});
176+
177+
it('should destroy CloseWatcher when overlay unmounts', function () {
178+
let onClose = jest.fn();
179+
let res = render(<Example isOpen onClose={onClose} />);
180+
expect(closeWatcherInstances.length).toBe(1);
181+
res.unmount();
182+
expect(closeWatcherInstances.length).toBe(0);
183+
});
184+
185+
it('should dismiss only the top-most overlay with nested overlays', function () {
186+
let onCloseOuter = jest.fn();
187+
let onCloseInner = jest.fn();
188+
render(<Example isOpen onClose={onCloseOuter} data-testid="outer" />);
189+
render(<Example isOpen onClose={onCloseInner} data-testid="inner" />);
190+
191+
// Each overlay gets its own CloseWatcher
192+
expect(closeWatcherInstances.length).toBe(2);
193+
194+
// Browser fires close on the most recently created watcher (inner overlay)
195+
closeWatcherInstances[1].onclose();
196+
expect(onCloseInner).toHaveBeenCalledTimes(1);
197+
expect(onCloseOuter).not.toHaveBeenCalled();
198+
});
199+
200+
it('should dismiss inner then outer with per-overlay watchers', function () {
201+
let onCloseOuter = jest.fn();
202+
let onCloseInner = jest.fn();
203+
render(<Example isOpen onClose={onCloseOuter} data-testid="outer" />);
204+
let inner = render(<Example isOpen onClose={onCloseInner} data-testid="inner" />);
205+
206+
expect(closeWatcherInstances.length).toBe(2);
207+
208+
// Dismiss inner overlay via its watcher
209+
closeWatcherInstances[1].onclose();
210+
expect(onCloseInner).toHaveBeenCalledTimes(1);
211+
212+
// Unmount inner - its watcher is destroyed
213+
inner.unmount();
214+
expect(closeWatcherInstances.length).toBe(1);
215+
216+
// Dismiss outer via its watcher
217+
closeWatcherInstances[0].onclose();
218+
expect(onCloseOuter).toHaveBeenCalledTimes(1);
219+
});
220+
});
131221
});

0 commit comments

Comments
 (0)