diff --git a/core/src/components/menu/menu.tsx b/core/src/components/menu/menu.tsx
index 4bf4564c40f..d9749f75b21 100644
--- a/core/src/components/menu/menu.tsx
+++ b/core/src/components/menu/menu.tsx
@@ -3,7 +3,7 @@ import { Build, Component, Element, Event, Host, Listen, Method, Prop, State, Wa
import { getTimeGivenProgression } from '@utils/animation/cubic-bezier';
import { focusFirstDescendant, focusLastDescendant } from '@utils/focus-trap';
import { GESTURE_CONTROLLER } from '@utils/gesture';
-import { shouldUseCloseWatcher } from '@utils/hardware-back-button';
+import { shouldUseCloseWatcher, updateCloseWatcher } from '@utils/hardware-back-button';
import type { Attributes } from '@utils/helpers';
import { inheritAriaAttributes, assert, clamp, isEndSide as isEnd } from '@utils/helpers';
import { printIonError } from '@utils/logging';
@@ -726,6 +726,7 @@ export class Menu implements ComponentInterface, MenuI {
// emit opened/closed events
this._isOpen = isOpen;
this.isAnimating = false;
+ updateCloseWatcher();
if (!this._isOpen) {
this.blocker.unblock();
}
diff --git a/core/src/components/menu/test/close-watcher/menu.e2e.ts b/core/src/components/menu/test/close-watcher/menu.e2e.ts
new file mode 100644
index 00000000000..d77b89c5eeb
--- /dev/null
+++ b/core/src/components/menu/test/close-watcher/menu.e2e.ts
@@ -0,0 +1,71 @@
+import { expect } from '@playwright/test';
+import { configs, test } from '@utils/test/playwright';
+
+/**
+ * This behavior does not vary across modes/directions
+ */
+configs({ modes: ['ios'], directions: ['ltr'] }).forEach(({ title, config }) => {
+ test.describe(title('menu: close watcher'), () => {
+ test('should close the menu on a close request and release the back button', async ({ page, skip }, testInfo) => {
+ testInfo.annotations.push({
+ type: 'issue',
+ description: 'https://github.com/ionic-team/ionic-framework/issues/29648',
+ });
+ skip.browser((browserName: string) => browserName !== 'chromium', 'Only Chromium supports the CloseWatcher API');
+
+ await page.setContent(
+ `
+
+
+
+ Menu Content
+
+
+ Main Content
+
+
+ `,
+ config
+ );
+
+ const menu = page.locator('ion-menu');
+ const ionDidOpen = await page.spyOnEvent('ionDidOpen');
+ const ionDidClose = await page.spyOnEvent('ionDidClose');
+
+ await menu.evaluate((el: HTMLIonMenuElement) => el.open());
+ await ionDidOpen.next();
+ expect(await page.evaluate(() => (window as any).liveCloseWatchers)).toBe(1);
+
+ // Escape sends a close request to the active CloseWatcher
+ await page.keyboard.press('Escape');
+
+ await ionDidClose.next();
+ await expect(menu).not.toHaveClass(/show-menu/);
+ expect(await page.evaluate(() => (window as any).liveCloseWatchers)).toBe(0);
+ });
+ });
+});
diff --git a/core/src/utils/config.ts b/core/src/utils/config.ts
index cca58752ee7..6a841a3fcf2 100644
--- a/core/src/utils/config.ts
+++ b/core/src/utils/config.ts
@@ -206,7 +206,10 @@ export interface IonicConfig {
/**
* @experimental
* If `true`, the [CloseWatcher API](https://github.com/WICG/close-watcher) will be used to handle
- * all Escape key and hardware back button presses to dismiss menus and overlays and to navigate.
+ * Escape key and Android back button presses that dismiss menus and overlays. It's only active
+ * while a menu or an overlay other than a toast is open, so back navigation works as usual
+ * otherwise. Hybrid apps still handle the hardware back button through the native `backbutton`
+ * event.
* Note that the `hardwareBackButton` config option must also be `true`.
*/
experimentalCloseWatcher?: boolean;
diff --git a/core/src/utils/hardware-back-button.ts b/core/src/utils/hardware-back-button.ts
index 96da99979fb..46b4c19c5c0 100644
--- a/core/src/utils/hardware-back-button.ts
+++ b/core/src/utils/hardware-back-button.ts
@@ -48,6 +48,26 @@ export const blockHardwareBackButton = () => {
document.addEventListener('backbutton', () => {});
};
+const closeWatcherConditions: (() => boolean)[] = [];
+let syncCloseWatcher: (() => void) | undefined;
+
+/**
+ * Registers a check for whether the back button
+ * has something to close. The CloseWatcher is only
+ * active while one of these checks passes.
+ */
+export const addCloseWatcherCondition = (condition: () => boolean) => {
+ closeWatcherConditions.push(condition);
+};
+
+/**
+ * Call this whenever something the back button
+ * can close opens or closes.
+ */
+export const updateCloseWatcher = () => {
+ syncCloseWatcher?.();
+};
+
export const startHardwareBackButton = () => {
const doc = document;
let busy = false;
@@ -105,16 +125,32 @@ export const startHardwareBackButton = () => {
};
/**
- * If the CloseWatcher is defined then
- * we don't want to also listen for the native
- * backbutton event otherwise we may get duplicate
- * events firing.
+ * Android WebView never sends the hardware back
+ * button to a CloseWatcher, so hybrid apps still
+ * need this. Browsers never fire backbutton.
*/
+ doc.addEventListener('backbutton', backButtonCallback);
+
if (shouldUseCloseWatcher()) {
let watcher: CloseWatcher | undefined;
- const configureWatcher = () => {
- watcher?.destroy();
+ /**
+ * An active CloseWatcher stops the back button from
+ * navigating, so only keep one while there's something to close.
+ */
+ syncCloseWatcher = () => {
+ const canClose = closeWatcherConditions.some((condition) => condition());
+
+ if (!canClose) {
+ watcher?.destroy();
+ watcher = undefined;
+ return;
+ }
+
+ if (watcher !== undefined) {
+ return;
+ }
+
watcher = new win!.CloseWatcher!();
/**
@@ -122,17 +158,18 @@ export const startHardwareBackButton = () => {
* the watcher gets destroyed.
* As a result, we need to re-configure
* the watcher so we can respond to other
- * close requests.
+ * close requests, including after one that
+ * closed nothing, like a modal whose canDismiss
+ * returned false.
*/
watcher!.onclose = () => {
+ watcher = undefined;
backButtonCallback();
- configureWatcher();
+ syncCloseWatcher?.();
};
};
- configureWatcher();
- } else {
- doc.addEventListener('backbutton', backButtonCallback);
+ syncCloseWatcher();
}
};
diff --git a/core/src/utils/menu-controller/index.ts b/core/src/utils/menu-controller/index.ts
index 5d170112340..6d90e958e87 100644
--- a/core/src/utils/menu-controller/index.ts
+++ b/core/src/utils/menu-controller/index.ts
@@ -1,6 +1,6 @@
import { doc } from '@utils/browser';
import type { BackButtonEvent } from '@utils/hardware-back-button';
-import { MENU_BACK_BUTTON_PRIORITY } from '@utils/hardware-back-button';
+import { MENU_BACK_BUTTON_PRIORITY, addCloseWatcherCondition } from '@utils/hardware-back-button';
import { printIonWarning } from '@utils/logging';
import type { MenuI, MenuControllerI } from '../../components/menu/menu-interface';
@@ -229,6 +229,7 @@ const createMenuController = (): MenuControllerI => {
registerAnimation('push', menuPushAnimation);
registerAnimation('overlay', menuOverlayAnimation);
+ addCloseWatcherCondition(() => _getOpenSync() !== undefined);
doc?.addEventListener('ionBackButton', (ev: BackButtonEvent) => {
const openMenu = _getOpenSync();
if (openMenu) {
diff --git a/core/src/utils/overlays.ts b/core/src/utils/overlays.ts
index 40bbb7b88fc..7a1dd014bd3 100644
--- a/core/src/utils/overlays.ts
+++ b/core/src/utils/overlays.ts
@@ -1,7 +1,7 @@
import { doc } from '@utils/browser';
import { focusFirstDescendant, focusLastDescendant, focusableQueryString } from '@utils/focus-trap';
import type { BackButtonEvent } from '@utils/hardware-back-button';
-import { shouldUseCloseWatcher } from '@utils/hardware-back-button';
+import { addCloseWatcherCondition, shouldUseCloseWatcher, updateCloseWatcher } from '@utils/hardware-back-button';
import { printIonError, printIonWarning } from '@utils/logging';
import { config } from '../global/config';
@@ -35,6 +35,12 @@ import {
let lastOverlayIndex = 0;
let lastId = 0;
+/**
+ * Every overlay except toast, which doesn't trap
+ * focus or close on back.
+ */
+const NON_TOAST_OVERLAYS = 'ion-alert,ion-action-sheet,ion-loading,ion-modal,ion-popover';
+
export const activeAnimations = new WeakMap();
type OverlayWithFocusTrapProps = HTMLIonOverlayElement & {
@@ -186,7 +192,7 @@ const focusElementInOverlay = (hostToFocus: HTMLElement | null | undefined, over
* Should NOT include: Toast
*/
const trapKeyboardFocus = (ev: Event, doc: Document) => {
- const lastOverlay = getPresentedOverlay(doc, 'ion-alert,ion-action-sheet,ion-loading,ion-modal,ion-popover');
+ const lastOverlay = getPresentedOverlay(doc, NON_TOAST_OVERLAYS);
const target = ev.target as HTMLElement | null;
/**
@@ -383,6 +389,12 @@ const connectListeners = (doc: Document) => {
true
);
+ /**
+ * Whether the overlay actually dismisses is checked at press time,
+ * since `backdropDismiss` can change while it's open.
+ */
+ addCloseWatcherCondition(() => getPresentedOverlay(doc, NON_TOAST_OVERLAYS) !== undefined);
+
// handle back-button click
doc.addEventListener('ionBackButton', (ev) => {
const lastOverlay = getPresentedOverlay(doc);
@@ -525,7 +537,8 @@ export const setRootAriaHidden = (hidden = false) => {
* Cleans up root `aria-hidden` and `backdrop-no-scroll` when
* an overlay is removed from the DOM without going through
* the `dismiss()` flow (e.g., when a framework unmounts the
- * overlay during a route change).
+ * overlay during a route change). Also releases the
+ * CloseWatcher if nothing is left to close.
*
* Should be called from an overlay's `disconnectedCallback`
* when the overlay was still presented at the time of removal.
@@ -535,6 +548,8 @@ export const cleanupRootFocusTrapAccessibility = () => {
return;
}
+ updateCloseWatcher();
+
const remainingOverlays = getPresentedOverlays(document);
const hasRemainingLocking = remainingOverlays.some((o) => locksAppRoot(o as OverlayWithFocusTrapProps));
@@ -559,7 +574,9 @@ const applyRootLock = (el: OverlayWithFocusTrapProps) => {
/**
* Re-applies the root lock that `cleanupRootFocusTrapAccessibility()` released.
- * Call from `connectedCallback` when the overlay is still presented.
+ * Call from `connectedCallback` when the overlay is still presented. Also
+ * re-arms the CloseWatcher, which every overlay needs, so that happens
+ * before the `locksAppRoot` check.
*
* A synchronous move keeps the overlay connected, so the lock survives. A
* detach with a re-insert in a later task releases it, which is the shape a
@@ -570,6 +587,8 @@ export const restoreRootFocusTrapAccessibility = (overlayEl: HTMLIonOverlayEleme
return;
}
+ updateCloseWatcher();
+
const el = overlayEl as OverlayWithFocusTrapProps;
if (!locksAppRoot(el)) {
return;
@@ -828,6 +847,12 @@ export const dismiss = async (
overlay.el.remove();
+ /**
+ * Release the back button if nothing
+ * else is left to close.
+ */
+ updateCloseWatcher();
+
return true;
};
@@ -844,6 +869,13 @@ const overlayAnimation = async (
// Make overlay visible in case it's hidden
baseEl.classList.remove('overlay-hidden');
+ /**
+ * Removing `overlay-hidden` makes the overlay count
+ * as presented, so back can dismiss it while it
+ * animates in.
+ */
+ updateCloseWatcher();
+
const aniRoot = overlay.el;
const animation = animationBuilder(aniRoot, opts);
diff --git a/core/src/utils/test/hardware-back-button.spec.ts b/core/src/utils/test/hardware-back-button.spec.ts
index 87f27d407cc..b195996c20d 100644
--- a/core/src/utils/test/hardware-back-button.spec.ts
+++ b/core/src/utils/test/hardware-back-button.spec.ts
@@ -1,7 +1,8 @@
+import { newSpecPage } from '@stencil/core/testing';
+
import type { BackButtonEvent } from '../../../src/interface';
import { startHardwareBackButton } from '../hardware-back-button';
import { config } from '../../global/config';
-import { win } from '@utils/browser';
describe('Hardware Back Button', () => {
beforeEach(() => startHardwareBackButton());
@@ -58,52 +59,216 @@ describe('Hardware Back Button', () => {
describe('Experimental Close Watcher', () => {
test('should not use the Close Watcher API when available', () => {
- const mockAPI = mockCloseWatcher();
+ const closeWatchers = mockCloseWatcher();
config.reset({ experimentalCloseWatcher: false });
startHardwareBackButton();
- expect(mockAPI.mock.calls).toHaveLength(0);
+ expect(closeWatchers.created).toBe(0);
});
- test('should use the Close Watcher API when available', () => {
- const mockAPI = mockCloseWatcher();
- config.reset({ experimentalCloseWatcher: true });
+ test('should still handle the native back button event', async () => {
+ await newCloseWatcherPage(``);
- startHardwareBackButton();
+ const cbSpy = jest.fn();
+ document.addEventListener('ionBackButton', (ev) => {
+ (ev as BackButtonEvent).detail.register(0, cbSpy);
+ });
+
+ dispatchBackButtonEvent();
- expect(mockAPI.mock.calls).toHaveLength(1);
+ expect(cbSpy).toHaveBeenCalled();
});
- test('Close Watcher should dispatch ionBackButton events', () => {
- const mockAPI = mockCloseWatcher();
- config.reset({ experimentalCloseWatcher: true });
+ // Fixes https://github.com/ionic-team/ionic-framework/issues/29648
+ test('should not intercept the back button while nothing is open', async () => {
+ const { closeWatchers } = await newCloseWatcherPage(``);
- startHardwareBackButton();
+ expect(closeWatchers.active()).toBeUndefined();
+ });
- const cbSpy = jest.fn();
- document.addEventListener('ionBackButton', cbSpy);
+ test('should not intercept the back button while only a toast is shown', async () => {
+ const { page, closeWatchers } = await newCloseWatcherPage(``);
- // Call onclose on Ionic's instance of CloseWatcher
- mockAPI.getMockImplementation()!().onclose();
+ await page.body.querySelector('ion-toast')!.present();
- expect(cbSpy).toHaveBeenCalled();
+ expect(closeWatchers.active()).toBeUndefined();
+ });
+
+ test('should dismiss a presented modal when the back button is pressed', async () => {
+ const { page, closeWatchers } = await newCloseWatcherPage(``);
+
+ const modal = page.body.querySelector('ion-modal')!;
+ await modal.present();
+
+ expect(closeWatchers.active()).toBeDefined();
+
+ const dismissed = modal.onDidDismiss();
+ closeWatchers.requestClose();
+
+ await expect(dismissed).resolves.toEqual(expect.objectContaining({ role: 'backdrop' }));
+ });
+
+ // Fixes https://github.com/ionic-team/ionic-framework/issues/29648
+ test('should stop intercepting the back button once the modal is dismissed', async () => {
+ const { page, closeWatchers } = await newCloseWatcherPage(``);
+
+ const modal = page.body.querySelector('ion-modal')!;
+ await modal.present();
+ await modal.dismiss();
+
+ expect(closeWatchers.active()).toBeUndefined();
+ });
+
+ test('should stop intercepting the back button when a presented modal is removed without dismissing', async () => {
+ const { page, closeWatchers } = await newCloseWatcherPage(``);
+
+ const modal = page.body.querySelector('ion-modal')!;
+ await modal.present();
+ modal.remove();
+
+ expect(closeWatchers.active()).toBeUndefined();
+ });
+
+ test('should keep intercepting the back button when a presented modal is moved', async () => {
+ const { page, closeWatchers } = await newCloseWatcherPage(``);
+
+ const modal = page.body.querySelector('ion-modal')!;
+ await modal.present();
+ page.body.querySelector('#target')!.appendChild(modal);
+
+ expect(closeWatchers.active()).toBeDefined();
+ });
+
+ test('should keep intercepting the back button while a modal is still open underneath', async () => {
+ const { page, closeWatchers } = await newCloseWatcherPage(`
+
+
+ `);
+
+ const bottom = page.body.querySelector('#bottom')!;
+ const top = page.body.querySelector('#top')!;
+ await bottom.present();
+ await top.present();
+
+ const topDismissed = top.onDidDismiss();
+ closeWatchers.requestClose();
+ await topDismissed;
+
+ const bottomDismissed = bottom.onDidDismiss();
+ closeWatchers.requestClose();
+
+ await expect(bottomDismissed).resolves.toEqual(expect.objectContaining({ role: 'backdrop' }));
+ expect(closeWatchers.active()).toBeUndefined();
+ });
+
+ test('should keep intercepting the back button when the modal refuses to dismiss', async () => {
+ const { page, closeWatchers } = await newCloseWatcherPage(``);
+
+ const modal = page.body.querySelector('ion-modal')!;
+ let dismissAttempts = 0;
+ modal.canDismiss = () => {
+ dismissAttempts++;
+ return Promise.resolve(false);
+ };
+ await modal.present();
+
+ closeWatchers.requestClose();
+ await page.waitForChanges();
+ closeWatchers.requestClose();
+ await page.waitForChanges();
+
+ expect(dismissAttempts).toBe(2);
+ });
+
+ test('should dismiss a modal that allows backdrop dismissal only after it was presented', async () => {
+ const { page, closeWatchers } = await newCloseWatcherPage(``);
+
+ const modal = page.body.querySelector('ion-modal')!;
+ modal.backdropDismiss = false;
+ await modal.present();
+ modal.backdropDismiss = true;
+
+ const dismissed = modal.onDidDismiss();
+ closeWatchers.requestClose();
+
+ await expect(dismissed).resolves.toEqual(expect.objectContaining({ role: 'backdrop' }));
+ });
+
+ test('should keep intercepting the back button while a toast is shown over a modal', async () => {
+ const { page, closeWatchers } = await newCloseWatcherPage(`
+
+
+ `);
+
+ await page.body.querySelector('ion-modal')!.present();
+ await page.body.querySelector('ion-toast')!.present();
+
+ expect(closeWatchers.active()).toBeDefined();
});
});
+/**
+ * Modules are reset first because overlays attach their back button
+ * listener once per module load, and every spec page gets a new document.
+ */
+const newCloseWatcherPage = async (html: string) => {
+ jest.resetModules();
+ const { Modal } = await import('../../components/modal/modal');
+ const { Toast } = await import('../../components/toast/toast');
+ const { startHardwareBackButton } = await import('../hardware-back-button');
+ const { config } = await import('../../global/config');
+
+ const page = await newSpecPage({ components: [Modal, Toast], html });
+
+ // Install the mock after newSpecPage, which resets the window
+ const closeWatchers = mockCloseWatcher();
+ config.reset({ experimentalCloseWatcher: true });
+ startHardwareBackButton();
+
+ return { page, closeWatchers };
+};
+
+/**
+ * Like the browser, only the newest watcher that hasn't
+ * been destroyed gets close requests.
+ */
const mockCloseWatcher = () => {
- const mockCloseWatcher = jest.fn();
- mockCloseWatcher.mockReturnValue({
- requestClose: () => null,
- close: () => null,
- destroy: () => null,
- oncancel: () => null,
- onclose: () => null,
- });
- (win as any).CloseWatcher = mockCloseWatcher;
+ const watchers: { destroyed: boolean; onclose: (() => void) | null; destroy: () => void }[] = [];
+
+ (window as any).CloseWatcher = class {
+ destroyed = false;
+ onclose: (() => void) | null = null;
+
+ constructor() {
+ watchers.push(this);
+ }
+
+ destroy() {
+ this.destroyed = true;
+ }
+ };
+
+ const active = () => watchers.filter((w) => !w.destroyed).slice(-1)[0];
- return mockCloseWatcher;
+ return {
+ get created() {
+ return watchers.length;
+ },
+ active,
+ /**
+ * Presses back. Like the browser, this destroys
+ * the watcher before firing `close`.
+ */
+ requestClose() {
+ const watcher = active();
+ if (watcher) {
+ watcher.destroyed = true;
+ watcher.onclose?.();
+ }
+ },
+ };
};
const dispatchBackButtonEvent = () => {