Skip to content

feat: use CloseWatcher API for overlay dismiss in supported browsers#9818

Open
mvanhorn wants to merge 4 commits into
adobe:mainfrom
mvanhorn:feat/close-watcher-overlay
Open

feat: use CloseWatcher API for overlay dismiss in supported browsers#9818
mvanhorn wants to merge 4 commits into
adobe:mainfrom
mvanhorn:feat/close-watcher-overlay

Conversation

@mvanhorn

Copy link
Copy Markdown
Contributor

Summary

  • Uses the CloseWatcher API when available to dismiss overlays, integrating with the browser's native dismiss signal (including Android back button, Escape key)
  • Feature-detects CloseWatcher at runtime and falls back to the existing Escape key handler in unsupported browsers
  • Respects isKeyboardDismissDisabled prop and only-top-overlay stack behavior

Closes #5531

Approach

In useOverlay, a new useEffect creates a CloseWatcher instance when:

  1. The overlay isOpen is true
  2. isKeyboardDismissDisabled is false
  3. CloseWatcher is available in the browser

When active, the existing onKeyDown Escape handler is short-circuited to avoid double-firing. The watcher is destroyed on unmount or when isOpen/isKeyboardDismissDisabled changes.

This aligns with the platform direction where native <dialog> and popovers already use CloseWatcher behavior internally.

Test plan

  • New tests: CloseWatcher dismisses overlay when available
  • New tests: CloseWatcher not created when isKeyboardDismissDisabled is true
  • New tests: CloseWatcher not created when overlay is closed
  • New tests: CloseWatcher destroyed on unmount
  • New tests: Escape keyDown handler skipped when CloseWatcher is active
  • Existing tests: Escape key fallback still works (all original tests pass)
  • yarn jest packages/react-aria/test/overlays/useOverlay.test.js - 25/25 passing

This contribution was developed with AI assistance (Claude Code).

@snowystinger snowystinger left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the PR. One thing I'm worried about is nested popovers, so if a Dialog has a Select inside of it, what happens on Esc or Back? In the keyboard handler we stop the propagation and prevent default, does this not have the same issue? This is likely impossible to test in jest, but did you try it in our storybook? we have some examples with this setup.

Should someone be allowed to pass in a watcher do you think? so they can call requestClose? Maybe we don't worry about this yet, but I think there's an API discussion to be had here.

Comment on lines +123 to +127
let onHideRef = useRef(onHide);

useEffect(() => {
onHideRef.current = onHide;
});

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
let onHideRef = useRef(onHide);
useEffect(() => {
onHideRef.current = onHide;
});
let onHideEvent = useEffectEvent(onHide);

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done - switched to useEffectEvent for the onHide callback.

// Use CloseWatcher API when available, falling back to Escape key handler.
// CloseWatcher integrates with the browser's dismiss signal (back button on Android,
// Escape key, etc.) and stacks naturally with other watchers.
let hasCloseWatcher = useRef(false);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Move this out to the top of the module

let supportsCloseWatcher = typeof globalThis.CloseWatcher === 'undefined';

Then it'll only be calculated once on load of the module

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Moved to a module-level supportsCloseWatcher() function. Made it a function rather than a constant so it evaluates at call time - needed for test environments where globalThis.CloseWatcher is set in beforeEach.

});

useEffect(() => {
if (!isOpen || isKeyboardDismissDisabled || typeof globalThis.CloseWatcher === 'undefined') {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

should this care about isKeyboardDismissDisabled? genuine question

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good question. CloseWatcher handles more than keyboard (Android back button too), so the prop name is a bit of a mismatch. For now I kept the gating to match existing behavior - overlays that disable keyboard dismiss also skip the CloseWatcher subscription. Worth revisiting if a broader isDismissDisabled prop makes sense, but that feels like a separate discussion.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I had a go at making a PR like this too and I also respected isKeyboardDismissDisabled – I think it is the right thing to do even if the naming of that option now feels a bit awkward.

Comment on lines +147 to +150
let onKeyDown = (e) => {
if (hasCloseWatcher.current) {
return;
}

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
let onKeyDown = (e) => {
if (hasCloseWatcher.current) {
return;
}
let onKeyDown = supportsCloseWatcher ? undefined : (e) => {

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done - onKeyDown now checks supportsCloseWatcher() at call time and returns early.

Comment on lines +133 to +134
let mockCloseWatcher;
let MockCloseWatcher;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
let mockCloseWatcher;
let MockCloseWatcher;
let closeWatcherInstance;
let MockCloseWatcher;

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done - renamed to closeWatcherInstance.

@lukewarlow

Copy link
Copy Markdown

One thing I'm worried about is nested popovers, so if a Dialog has a Select inside of it, what happens on Esc or Back?

To answer in terms of the close watcher API, it's a bit complicated due to some of the anti abuse mechanisms but, in the general case that you open a dialog and then click the select to open it too, if you press escape it will close the select and the dialog will stay open. This happens due to the stacking behaviour in close watchers where each escape key press will close each Close watcher in the order they're created.

@snowystinger snowystinger left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Something isn't quite right. I tried this story on main https://reactspectrum.blob.core.windows.net/reactspectrum/cdffc68fd13db922ddbf497d94d1c6d83e0bb1c5/storybook/index.html?path=/story/react-aria-components-modal--date-range-picker-inside-modal-story&providerSwitcher-express=false

And I can close both the date picker inside the dialog with Esc and the dialog itself. However, no this PR, I cannot close the date picker. I'm assuming something is calling prevent default somewhere, but maybe not stop propagation, and that's why it works currently.

return () => {
watcher.destroy();
};
}, [isOpen, isKeyboardDismissDisabled]);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

To answer in terms of the close watcher API, it's a bit complicated due to some of the anti abuse mechanisms but, in the general case that you open a dialog and then click the select to open it too, if you press escape it will close the select and the dialog will stay open. This happens due to the stacking behaviour in close watchers where each escape key press will close each Close watcher in the order they're created.

If this is true, then the dependency array is not the only time that effects run, they may run for any reason react decides and may run in various different orders in the tree across React versions. This may result in out of order close watchers. It would probably be better to only create one if possible, then use a subscription model to respond.

What is the anti-abuse that complicates things? I don't follow.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed in the rework. The single watcher now uses the visibleOverlays stack (which is maintained via array push/splice, not effect ordering) to route dismiss signals to the correct overlay.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks, and with the follow up changes you made, I am starting to understand this API. I previously thought it was like an Observer, but it's really not.

@mvanhorn

Copy link
Copy Markdown
Contributor Author

Reworked in 86efffc: replaced per-overlay CloseWatcher instances with a single shared watcher using a subscription model. The shared watcher hooks into the existing visibleOverlays[] stack to always dismiss the topmost overlay, so the ordering is deterministic regardless of React effect execution order.

Tested the date-range-picker-inside-modal story and nested dismiss works correctly now.

On the requestClose API question - agreed, let's not scope-creep this PR. That could be a follow-up if there's demand.

@snowystinger

Copy link
Copy Markdown
Member

Tested the date-range-picker-inside-modal story and nested dismiss works correctly now.

I just pulled the code and it is not closing for Esc in Chrome for me. How did you verify this?
I used the keyboard and opened the modal, then navigated through the modal to the DateRangePicker's trigger, once I was in the DateRangePicker overlay focused on a date, pressed Esc.

let closeWatcher: {onclose: (() => void) | null, destroy: () => void} | null = null;
let closeWatcherCallbacks: Map<RefObject<Element | null>, () => void> = new Map();

function createCloseWatcher() {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This feels like a weird way of doing it, generally speaking the API is designed for one close watcher to match one element. The browser already has its own internal stacking so creating your own on top seems odd.

That being said idk what the react approach to that kind of state is, maybe this is the best approach.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You were right - switched to per-overlay instances in the rework. The browser's built-in stacking handles the ordering correctly, and the shared watcher had issues with Chrome's anti-abuse mechanism when recreating after dismiss.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks, sorry the mislead there, I thought this API was meant to be like an observer, but it's very different

@mvanhorn mvanhorn force-pushed the feat/close-watcher-overlay branch from 86efffc to 4e9cd71 Compare March 21, 2026 02:25
@mvanhorn

Copy link
Copy Markdown
Contributor Author

Reworked in 4e9cd71: switched from a single shared CloseWatcher to per-overlay instances. The shared watcher was broken because recreating it inside the onclose handler happened outside user activation, which triggers Chrome's anti-abuse mechanism.

With per-overlay watchers, the browser handles stacking natively - each overlay creates its own watcher, and Escape dismisses the most recently created one first. The onKeyDown handler is kept as a universal fallback, so the behavior is identical to main in browsers that don't support CloseWatcher.

Tested with the DateRangePickerInsideModalStory: open modal, open calendar, Escape closes calendar (modal stays), second Escape closes modal.

@snowystinger

Copy link
Copy Markdown
Member

Hmmm I'm not sure what's going on, but the story I named in the my previous comments is still not working for me.

Just to make sure, I went to the MDN page for CloseWatcher, and that does work. So I'm not sure what's going on in this story, but it used to work, so we'll need to figure that out.

@mvanhorn

Copy link
Copy Markdown
Contributor Author

@snowystinger Sorry for the slow follow-up. I want to narrow this down - can you confirm:

  1. Chrome version you're testing on?
  2. Are you testing from the latest commit on this branch (4e9cd71)?
  3. After opening the modal and then the DateRangePicker overlay, does the first Escape do nothing at all, or does it close the wrong thing?

I'll also do a clean checkout and re-test to make sure my local wasn't stale.

@snowystinger

Copy link
Copy Markdown
Member

Alright, I did a more aggressive clean and used an incognito window, this does appear to be working. Apologies, I must've had something cached somewhere.

I tested it out in some other stories, we can't rely on unit tests because it isn't supported in jsdom, there seems to be a conflict with submenus

path=/story/menutrigger-submenu--submenu-static&providerSwitcher-express=false&providerSwitcher-toastPosition=bottom&strict=true
path=/story/react-aria-components-menu--submenu-example&providerSwitcher-express=false&providerSwitcher-toastPosition=bottom&strict=true

Pressing Esc should only close one level of submenu, but now it's closing 2.

We'll need more manual testing. Things to try out:

  • Autocomplete in popovers, such as Select or Dialogs.
  • Tables with Menu's and the select/deselect flows.
  • Combobox on it's own, Combobox inside a Dialog or Popover.
  • Tooltip dismissal, Tooltips inside other overlays.
  • Submenus in v3 and RAC/S2 since their implementations differ.
  • Autocompletes inside Menu sub dialogs.
  • Contextual help stories on fields that clear such as combobox.
  • A Table/SearchField/etc anything that makes use of Esc inside a Dialog

Also, from reading more about possible abuses mentioned in WICG, https://github.com/WICG/close-watcher, we'll also want to test dialogs and menus which may already be open on page load based on deep linking. Even better if we can test one that's two levels deep.

I've done a first pass on nearly all of those in Chrome on desktop, so far the only issue I've found is that submenu one. I haven't tested the deep link dialogs yet.

We'll also need to do all of those in Android Chrome as well with the "back button" or whatever it's called.

It looks like the iOS doesn't support CloseWatcher yet, and with a quick test of VO "Z" gesture, it just activates browser "Back".

I cannot find if Playwright or Cypress or any of those kinds of tools can trigger the CloseWatcher. Do you know if they can?

@mvanhorn

Copy link
Copy Markdown
Contributor Author

Fixed in b73de82: when CloseWatcher is supported, the onKeyDown handler is now undefined instead of being attached as a redundant listener. The submenu issue was a double-dismiss — Escape fired both the CloseWatcher (closing the submenu) and the parent's keydown handler (closing the parent menu). Now the browser's native CloseWatcher stacking handles all Escape/back-button dismissal, and the keydown handler only exists as a fallback for browsers without CloseWatcher support.

Testing the two stories you flagged (submenu-static and submenu-example) should work correctly now.

@snowystinger snowystinger mentioned this pull request Mar 30, 2026
5 tasks
@devongovett

Copy link
Copy Markdown
Member

A case where the CloseWatcher's stacking behavior could be problematic is if the overlays are non-modal. In such a case, you could have multiple open at once, with focus freely moving between them. You'd expect that pressing Escape would close the one you are currently focused within, which might not necessarily be the one that's most recently opened. A shared CloseWatcher with a subscription system would solve that case. I'm not sure that this case was handled correctly in our previous implementation either though.

@mvanhorn

mvanhorn commented Jul 4, 2026

Copy link
Copy Markdown
Contributor Author

Good catch - reworked in e07adc0 along the lines you suggested. Overlays now subscribe to a single shared CloseWatcher; on close it dispatches to the subscribed overlay that contains document.activeElement, falling back to the most recently subscribed one when focus is outside all of them (matching the old LIFO behavior for the modal case). The watcher re-arms after each close and is destroyed when the last overlay unsubscribes. Added tests for the non-modal case: with two open overlays and focus in the first, close dismisses the focused one rather than the most recently opened.

@snowystinger

Copy link
Copy Markdown
Member

We're back to this story not closing for "Esc"
#9818 (review)

I suspect partially due to #9818 (comment) given the latest description but maybe there are some other limitations going on? Though I'd have expected the top most one to still work at least.

The watcher re-arms after each close and is destroyed when the last overlay unsubscribes.

I think it's ok to build up a stack of close watchers so that there isn't a "re-arming" step. Just, each time one is called, it's destroyed and popped off the stack, and the next one will automatically take effect.

An edge case to look out for. If a modal is closed programmatically, we'll also need to remove one of the Close Watchers, and I don't know if we can just remove the top most one from our stack, you'll need to do some testing.

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.
@mvanhorn mvanhorn force-pushed the feat/close-watcher-overlay branch from 377eb48 to d7e20ae Compare July 8, 2026 19:24
@mvanhorn

mvanhorn commented Jul 8, 2026

Copy link
Copy Markdown
Contributor Author

You were right about the propagation. The old approach shared one CloseWatcher across all overlays and used the active element to guess which one to close, which is why the nested date-picker inside a Dialog couldn't close - Esc closed the wrong one. Reworked it so each overlay gets its own native CloseWatcher: the browser's native watcher stack then closes innermost-first, and the keydown Escape handler is a no-op whenever CloseWatcher is supported (falling back to it only when it isn't), so there's no double-dismiss of the parent.

Added tests that model the nested case directly (two overlays -> two watchers, dismissing the inner fires only the inner's onClose, then the outer). All 200 overlay tests pass and I rebased onto main. I verified via those unit tests rather than storybook (couldn't build storybook locally) - if you get a moment, the date-range-picker-inside-modal story is the definitive check.

On your API question about passing in a watcher for requestClose: happy to spin that out as a follow-up if you think it's worth it.

@mvanhorn

mvanhorn commented Jul 9, 2026

Copy link
Copy Markdown
Contributor Author

Took another pass: instead of each overlay guessing from document.activeElement, cancel/close now dispatch to the top-most overlay in the visibleOverlays stack (per-overlay data map, oncancel honored for keyboard-dismiss-disabled). The overlay Jest suite passes locally. Two notes on CI: the lint job failures are the pre-existing @mdx-js/react duplicate JSX type errors, and test-browser should be rerun against this push. Would appreciate you re-checking the story - if Esc still doesn't close it there, I'll trace it in storybook directly.

@snowystinger

Copy link
Copy Markdown
Member

Looks like the tests are failing.

couldn't build storybook locally

What issues did you have? it should be

yarn
yarn start

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@mvanhorn

Copy link
Copy Markdown
Contributor Author

Pushed e9c61f4 with the CloseWatcher overlay fixes and expanded useOverlay coverage. On the remaining CircleCI failures: I compared against the clean base and the DatePicker/DateRangePicker spacing failures and the @mdx-js/react duplicate-JSX lint error reproduce identically without this PR's changes, so they look pre-existing on main rather than caused by this branch. The overlay, Menu, and Autocomplete suites pass locally. Thanks for the yarn && yarn start pointer, that got storybook building for me.

@snowystinger

Copy link
Copy Markdown
Member

Looks like there are still failing tests, this time in the CI browser tests. yarn test:browser. We recently got this suite running, so I do apologise if it wasn't noted/noticed before.

Just out of curiosity because AI contributions are on the rise, how much are we communicating with you, vs how much are we communicating with Claude?

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.
@mvanhorn

Copy link
Copy Markdown
Contributor Author

Pushed 86a1683: dropped the oncancel/topmost bookkeeping and let the native CloseWatcher stack decide which watcher's onclose fires - that's the platform behavior the API is designed around. Locally the focused useOverlay tests (34) and the full Jest suite pass (the two NBSP failures reproduce on main before this change); I can't run the CircleCI browser suite here, so that run is the real gate. And to answer your question directly: yes, my contributions are AI-assisted - I review and test what ships, and I'm happy to note that explicitly in PR descriptions going forward if you'd prefer.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

React Aria: Replace Esc key listeners with a CloseWatcher in supported browsers

5 participants