From 746fa9fef5dd9289d930068e772c4ed02160e227 Mon Sep 17 00:00:00 2001 From: Liam DeBeasi Date: Thu, 14 Dec 2023 10:57:16 -0500 Subject: [PATCH 1/9] feat: add experimental hardware back button support in browsers --- core/src/components/app/app.tsx | 4 ++- core/src/components/menu/menu.tsx | 3 +- core/src/utils/config.ts | 8 +++++ core/src/utils/hardware-back-button.ts | 50 ++++++++++++++++++++++++-- core/src/utils/overlays.ts | 38 +++++++++++++++----- 5 files changed, 89 insertions(+), 14 deletions(-) diff --git a/core/src/components/app/app.tsx b/core/src/components/app/app.tsx index b0fc4731b3c..45d9067872c 100644 --- a/core/src/components/app/app.tsx +++ b/core/src/components/app/app.tsx @@ -1,6 +1,7 @@ import type { ComponentInterface } from '@stencil/core'; import { Build, Component, Element, Host, Method, h } from '@stencil/core'; import type { FocusVisibleUtility } from '@utils/focus-visible'; +import { shoudUseCloseWatcher } from '@utils/hardware-back-button'; import { isPlatform } from '@utils/platform'; import { config } from '../../global/config'; @@ -34,7 +35,8 @@ export class App implements ComponentInterface { import('../../utils/input-shims/input-shims').then((module) => module.startInputShims(config, platform)); } const hardwareBackButtonModule = await import('../../utils/hardware-back-button'); - if (config.getBoolean('hardwareBackButton', isHybrid)) { + const supportsHardwareBackButtonEvents = isHybrid || shoudUseCloseWatcher; + if (config.getBoolean('hardwareBackButton', supportsHardwareBackButtonEvents)) { hardwareBackButtonModule.startHardwareBackButton(); } else { hardwareBackButtonModule.blockHardwareBackButton(); diff --git a/core/src/components/menu/menu.tsx b/core/src/components/menu/menu.tsx index 49250518378..34a43d4a9e4 100644 --- a/core/src/components/menu/menu.tsx +++ b/core/src/components/menu/menu.tsx @@ -2,6 +2,7 @@ import type { ComponentInterface, EventEmitter } from '@stencil/core'; import { Build, Component, Element, Event, Host, Listen, Method, Prop, State, Watch, h } from '@stencil/core'; import { getTimeGivenProgression } from '@utils/animation/cubic-bezier'; import { GESTURE_CONTROLLER } from '@utils/gesture'; +import { shoudUseCloseWatcher } from '@utils/hardware-back-button'; import type { Attributes } from '@utils/helpers'; import { inheritAriaAttributes, assert, clamp, isEndSide as isEnd } from '@utils/helpers'; import { menuController } from '@utils/menu-controller'; @@ -321,7 +322,6 @@ export class Menu implements ComponentInterface, MenuI { } } - @Listen('keydown') onKeydown(ev: KeyboardEvent) { if (ev.key === 'Escape') { this.close(); @@ -783,6 +783,7 @@ export class Menu implements ComponentInterface, MenuI { return ( void) => Promise | void | null; @@ -13,6 +17,15 @@ interface HandlerRegister { id: number; } +/** + * CloseWatcher is a newer API that lets + * use detect the hardware back button event + * in a web browser: https://caniuse.com/?search=closewatcher + * However, not every browser supports it yet. + */ +export const shoudUseCloseWatcher = + config.get('experimentalCloseWatcher', false) && win !== undefined && 'CloseWatcher' in win; + /** * When hardwareBackButton: false in config, * we need to make sure we also block the default @@ -29,9 +42,9 @@ export const blockHardwareBackButton = () => { export const startHardwareBackButton = () => { const doc = document; - let busy = false; - doc.addEventListener('backbutton', () => { + + const backButtonCallback = () => { if (busy) { return; } @@ -81,7 +94,38 @@ export const startHardwareBackButton = () => { }; processHandlers(); - }); + }; + + /** + * 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. + */ + if (shoudUseCloseWatcher) { + let watcher: any; + + const configureWatcher = () => { + watcher?.destroy(); + watcher = new (win as any).CloseWatcher(); + + /** + * Once a close request happens + * the watcher gets destroyed. + * As a result, we need to re-configure + * the watcher so we can respond to other + * close requests. + */ + watcher.onclose = () => { + backButtonCallback(); + configureWatcher(); + }; + }; + + configureWatcher(); + } else { + doc.addEventListener('backbutton', backButtonCallback); + } }; export const OVERLAY_BACK_BUTTON_PRIORITY = 100; diff --git a/core/src/utils/overlays.ts b/core/src/utils/overlays.ts index bd56ac36844..cca1b1733cb 100644 --- a/core/src/utils/overlays.ts +++ b/core/src/utils/overlays.ts @@ -1,5 +1,6 @@ import { doc } from '@utils/browser'; import type { BackButtonEvent } from '@utils/hardware-back-button'; +import { shoudUseCloseWatcher } from '@utils/hardware-back-button'; import { config } from '../global/config'; import { getIonMode } from '../global/ionic-global'; @@ -353,20 +354,39 @@ const connectListeners = (doc: Document) => { const lastOverlay = getPresentedOverlay(doc); if (lastOverlay?.backdropDismiss) { (ev as BackButtonEvent).detail.register(OVERLAY_BACK_BUTTON_PRIORITY, () => { - return lastOverlay.dismiss(undefined, BACKDROP); + /** + * Do not return this promise otherwise + * the hardware back button utility will + * be blocked until the overlay dismisses. + * This is important for a modal with canDismiss. + * If the application presents a confirmation alert + * in the "canDismiss" callback, then it will be impossible + * to use the hardware back button to dismiss the alert + * dialog because the hardware back button utility + * is blocked on waiting for the modal to dismiss. + */ + lastOverlay.dismiss(undefined, BACKDROP); }); } }); - // handle ESC to close overlay - doc.addEventListener('keydown', (ev) => { - if (ev.key === 'Escape') { - const lastOverlay = getPresentedOverlay(doc); - if (lastOverlay?.backdropDismiss) { - lastOverlay.dismiss(undefined, BACKDROP); + /** + * Handle ESC to close overlay + * CloseWatcher also handles pressing the Esc + * key, so if a browser supports CloseWatcher then + * this behavior will be handled via the ionBackButton + * event. + */ + if (!shoudUseCloseWatcher) { + doc.addEventListener('keydown', (ev) => { + if (ev.key === 'Escape') { + const lastOverlay = getPresentedOverlay(doc); + if (lastOverlay?.backdropDismiss) { + lastOverlay.dismiss(undefined, BACKDROP); + } } - } - }); + }); + } } }; From c131f36c1b38d6ea95d9a41f2a6faaf2e37131d1 Mon Sep 17 00:00:00 2001 From: Liam DeBeasi Date: Thu, 14 Dec 2023 11:01:12 -0500 Subject: [PATCH 2/9] comments --- core/src/components/menu/menu.tsx | 5 +++++ core/src/utils/hardware-back-button.ts | 5 +++++ 2 files changed, 10 insertions(+) diff --git a/core/src/components/menu/menu.tsx b/core/src/components/menu/menu.tsx index 34a43d4a9e4..8b270c685ae 100644 --- a/core/src/components/menu/menu.tsx +++ b/core/src/components/menu/menu.tsx @@ -781,6 +781,11 @@ export class Menu implements ComponentInterface, MenuI { const { type, disabled, isPaneVisible, inheritedAttributes, side } = this; const mode = getIonMode(this); + /** + * If the Close Watcher is enabled then + * the ionBackButton listener in the menu controller + * will handle closing the menu when Escape is pressed. + */ return ( { const configureWatcher = () => { watcher?.destroy(); + + /** + * Since CloseWatcher is experimental, there + * are no types available for it yet. + */ watcher = new (win as any).CloseWatcher(); /** From 3bc9c0976b315779d8ade9660a9c1c9f30726f7c Mon Sep 17 00:00:00 2001 From: Liam DeBeasi Date: Thu, 14 Dec 2023 12:41:04 -0500 Subject: [PATCH 3/9] add types --- core/src/utils/browser/index.ts | 27 +++++++++++++++++++++++++- core/src/utils/hardware-back-button.ts | 12 ++++-------- 2 files changed, 30 insertions(+), 9 deletions(-) diff --git a/core/src/utils/browser/index.ts b/core/src/utils/browser/index.ts index fb400d142c6..694abc7f516 100644 --- a/core/src/utils/browser/index.ts +++ b/core/src/utils/browser/index.ts @@ -72,7 +72,32 @@ type IonicEvents = { ): void; }; -type IonicWindow = Window & IonicEvents; +export interface CloseWatcher extends EventTarget { + new (options?: CloseWatcherOptions): any; + requestClose(): void; + close(): void; + destroy(): void; + + oncancel: (event: Event) => void | null; + onclose: (event: Event) => void | null; +} + +interface CloseWatcherOptions { + signal: AbortSignal; +} + +/** + * Experimental browser features that + * are selectively used inside of Ionic + * Since they are experimental they typically + * do not have types yet, so we can add custom ones + * here until types are available. + */ +type ExperimentalWindowFeatures = { + CloseWatcher?: CloseWatcher; +}; + +type IonicWindow = Window & IonicEvents & ExperimentalWindowFeatures; type IonicDocument = Document & IonicEvents; export const win: IonicWindow | undefined = typeof window !== 'undefined' ? window : undefined; diff --git a/core/src/utils/hardware-back-button.ts b/core/src/utils/hardware-back-button.ts index 9940355ef87..56aea3f5694 100644 --- a/core/src/utils/hardware-back-button.ts +++ b/core/src/utils/hardware-back-button.ts @@ -1,4 +1,5 @@ import { win } from '@utils/browser'; +import type { CloseWatcher } from '@utils/browser'; import { config } from '../global/config'; @@ -103,16 +104,11 @@ export const startHardwareBackButton = () => { * events firing. */ if (shoudUseCloseWatcher) { - let watcher: any; + let watcher: CloseWatcher | undefined; const configureWatcher = () => { watcher?.destroy(); - - /** - * Since CloseWatcher is experimental, there - * are no types available for it yet. - */ - watcher = new (win as any).CloseWatcher(); + watcher = new win!.CloseWatcher!(); /** * Once a close request happens @@ -121,7 +117,7 @@ export const startHardwareBackButton = () => { * the watcher so we can respond to other * close requests. */ - watcher.onclose = () => { + watcher!.onclose = () => { backButtonCallback(); configureWatcher(); }; From 6b65dcbde8f222d9b36b789d7d34e7f171c70cb2 Mon Sep 17 00:00:00 2001 From: Liam DeBeasi Date: Tue, 19 Dec 2023 17:38:59 -0500 Subject: [PATCH 4/9] Update core/src/utils/overlays.ts Co-authored-by: Maria Hutt --- core/src/utils/overlays.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/core/src/utils/overlays.ts b/core/src/utils/overlays.ts index cca1b1733cb..e14fdf7a71c 100644 --- a/core/src/utils/overlays.ts +++ b/core/src/utils/overlays.ts @@ -371,7 +371,7 @@ const connectListeners = (doc: Document) => { }); /** - * Handle ESC to close overlay + * Handle ESC to close overlay. * CloseWatcher also handles pressing the Esc * key, so if a browser supports CloseWatcher then * this behavior will be handled via the ionBackButton From f34e136df164854d6e9189ad9f2250e972f45539 Mon Sep 17 00:00:00 2001 From: Liam DeBeasi Date: Thu, 4 Jan 2024 14:15:45 -0500 Subject: [PATCH 5/9] chore: warn if hardwareBackButton is disabled --- core/src/components/app/app.tsx | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/core/src/components/app/app.tsx b/core/src/components/app/app.tsx index 45d9067872c..f17e9f17d9f 100644 --- a/core/src/components/app/app.tsx +++ b/core/src/components/app/app.tsx @@ -2,6 +2,7 @@ import type { ComponentInterface } from '@stencil/core'; import { Build, Component, Element, Host, Method, h } from '@stencil/core'; import type { FocusVisibleUtility } from '@utils/focus-visible'; import { shoudUseCloseWatcher } from '@utils/hardware-back-button'; +import { printIonWarning } from '@utils/logging'; import { isPlatform } from '@utils/platform'; import { config } from '../../global/config'; @@ -39,6 +40,16 @@ export class App implements ComponentInterface { if (config.getBoolean('hardwareBackButton', supportsHardwareBackButtonEvents)) { hardwareBackButtonModule.startHardwareBackButton(); } else { + /** + * If an app sets hardwareBackButton: false and experimentalCloseWatcher: true + * then the close watcher will not be used. + */ + if (shoudUseCloseWatcher) { + printIonWarning( + 'experimentalCloseWatcher was set to `true`, but hardwareBackButton was set to `false`. Both config options must be `true` for the Close Watcher API to be used.' + ); + } + hardwareBackButtonModule.blockHardwareBackButton(); } if (typeof (window as any) !== 'undefined') { From c7e98591b136f94be76f391a0a27477beed14dab Mon Sep 17 00:00:00 2001 From: Liam DeBeasi Date: Thu, 4 Jan 2024 14:50:20 -0500 Subject: [PATCH 6/9] test: add smoke tests --- core/src/components/app/app.tsx | 4 +- core/src/components/menu/menu.tsx | 2 +- core/src/utils/hardware-back-button.ts | 4 +- core/src/utils/overlays.ts | 2 +- .../utils/test/hardware-back-button.spec.ts | 37 +++++++++++++++++++ 5 files changed, 43 insertions(+), 6 deletions(-) diff --git a/core/src/components/app/app.tsx b/core/src/components/app/app.tsx index f17e9f17d9f..44a67d2a156 100644 --- a/core/src/components/app/app.tsx +++ b/core/src/components/app/app.tsx @@ -36,7 +36,7 @@ export class App implements ComponentInterface { import('../../utils/input-shims/input-shims').then((module) => module.startInputShims(config, platform)); } const hardwareBackButtonModule = await import('../../utils/hardware-back-button'); - const supportsHardwareBackButtonEvents = isHybrid || shoudUseCloseWatcher; + const supportsHardwareBackButtonEvents = isHybrid || shoudUseCloseWatcher(); if (config.getBoolean('hardwareBackButton', supportsHardwareBackButtonEvents)) { hardwareBackButtonModule.startHardwareBackButton(); } else { @@ -44,7 +44,7 @@ export class App implements ComponentInterface { * If an app sets hardwareBackButton: false and experimentalCloseWatcher: true * then the close watcher will not be used. */ - if (shoudUseCloseWatcher) { + if (shoudUseCloseWatcher()) { printIonWarning( 'experimentalCloseWatcher was set to `true`, but hardwareBackButton was set to `false`. Both config options must be `true` for the Close Watcher API to be used.' ); diff --git a/core/src/components/menu/menu.tsx b/core/src/components/menu/menu.tsx index 8b270c685ae..a3574e1a2b2 100644 --- a/core/src/components/menu/menu.tsx +++ b/core/src/components/menu/menu.tsx @@ -788,7 +788,7 @@ export class Menu implements ComponentInterface, MenuI { */ return ( config.get('experimentalCloseWatcher', false) && win !== undefined && 'CloseWatcher' in win; /** @@ -103,7 +103,7 @@ export const startHardwareBackButton = () => { * backbutton event otherwise we may get duplicate * events firing. */ - if (shoudUseCloseWatcher) { + if (shoudUseCloseWatcher()) { let watcher: CloseWatcher | undefined; const configureWatcher = () => { diff --git a/core/src/utils/overlays.ts b/core/src/utils/overlays.ts index e14fdf7a71c..0ea4cb62727 100644 --- a/core/src/utils/overlays.ts +++ b/core/src/utils/overlays.ts @@ -377,7 +377,7 @@ const connectListeners = (doc: Document) => { * this behavior will be handled via the ionBackButton * event. */ - if (!shoudUseCloseWatcher) { + if (!shoudUseCloseWatcher()) { doc.addEventListener('keydown', (ev) => { if (ev.key === 'Escape') { const lastOverlay = getPresentedOverlay(doc); diff --git a/core/src/utils/test/hardware-back-button.spec.ts b/core/src/utils/test/hardware-back-button.spec.ts index a4e5bbb7c45..5cb4cab41c5 100644 --- a/core/src/utils/test/hardware-back-button.spec.ts +++ b/core/src/utils/test/hardware-back-button.spec.ts @@ -1,5 +1,7 @@ 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()); @@ -54,6 +56,41 @@ describe('Hardware Back Button', () => { }); }); +describe('Experimental Close Watcher', () => { + test('should not use the Close Watcher API when available', () => { + const mockAPI = mockCloseWatcher(); + + config.reset({ experimentalCloseWatcher: false }); + + startHardwareBackButton(); + + expect(mockAPI.mock.calls).toHaveLength(0); + }); + test('should use the Close Watcher API when available', () => { + const mockAPI = mockCloseWatcher(); + + config.reset({ experimentalCloseWatcher: true }); + + startHardwareBackButton(); + + expect(mockAPI.mock.calls).toHaveLength(1); + }); +}); + +const mockCloseWatcher = () => { + const mockCloseWatcher = jest.fn(); + mockCloseWatcher.mockReturnValue({ + requestClose: () => null, + close: () => null, + destroy: () => null, + oncancel: () => null, + onclose: () => null, + }); + (win as any).CloseWatcher = mockCloseWatcher; + + return mockCloseWatcher; +}; + const dispatchBackButtonEvent = () => { const ev = new Event('backbutton'); document.dispatchEvent(ev); From f0dc0ed404f512faaff11d74899d3da8c8da4d3e Mon Sep 17 00:00:00 2001 From: Liam DeBeasi Date: Thu, 4 Jan 2024 14:53:53 -0500 Subject: [PATCH 7/9] add comment --- core/src/utils/hardware-back-button.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/core/src/utils/hardware-back-button.ts b/core/src/utils/hardware-back-button.ts index 517ce15d0cf..1005497faa1 100644 --- a/core/src/utils/hardware-back-button.ts +++ b/core/src/utils/hardware-back-button.ts @@ -23,6 +23,12 @@ interface HandlerRegister { * use detect the hardware back button event * in a web browser: https://caniuse.com/?search=closewatcher * However, not every browser supports it yet. + * + * This needs to be a function so that we can + * check the config once it has been set. + * Otherwise, this code would be evaluated the + * moment this file is evaluated which could be + * before the config is set. */ export const shoudUseCloseWatcher = () => config.get('experimentalCloseWatcher', false) && win !== undefined && 'CloseWatcher' in win; From 276f0da6417ba11e99ae5c4eb1c6c5115a0fe72a Mon Sep 17 00:00:00 2001 From: Liam DeBeasi Date: Thu, 4 Jan 2024 15:23:01 -0500 Subject: [PATCH 8/9] add another test --- .../src/utils/test/hardware-back-button.spec.ts | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/core/src/utils/test/hardware-back-button.spec.ts b/core/src/utils/test/hardware-back-button.spec.ts index 5cb4cab41c5..45894fac3a7 100644 --- a/core/src/utils/test/hardware-back-button.spec.ts +++ b/core/src/utils/test/hardware-back-button.spec.ts @@ -75,6 +75,21 @@ describe('Experimental Close Watcher', () => { expect(mockAPI.mock.calls).toHaveLength(1); }); + test('Close Watcher should dispatch ionBackButton events', () => { + const mockAPI = mockCloseWatcher(); + + config.reset({ experimentalCloseWatcher: true }); + + startHardwareBackButton(); + + const cbSpy = jest.fn(); + document.addEventListener('ionBackButton', cbSpy); + + // Call onclose on Ionic's instance of CloseWatcher + mockAPI.getMockImplementation()!().onclose(); + + expect(cbSpy).toHaveBeenCalled(); + }); }); const mockCloseWatcher = () => { @@ -89,7 +104,7 @@ const mockCloseWatcher = () => { (win as any).CloseWatcher = mockCloseWatcher; return mockCloseWatcher; -}; +} const dispatchBackButtonEvent = () => { const ev = new Event('backbutton'); From 98fc331b8da5a81f4234583159bc123cda325ef0 Mon Sep 17 00:00:00 2001 From: Liam DeBeasi Date: Thu, 4 Jan 2024 15:29:11 -0500 Subject: [PATCH 9/9] chore: lint --- core/src/utils/test/hardware-back-button.spec.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/core/src/utils/test/hardware-back-button.spec.ts b/core/src/utils/test/hardware-back-button.spec.ts index 45894fac3a7..87f27d407cc 100644 --- a/core/src/utils/test/hardware-back-button.spec.ts +++ b/core/src/utils/test/hardware-back-button.spec.ts @@ -104,7 +104,7 @@ const mockCloseWatcher = () => { (win as any).CloseWatcher = mockCloseWatcher; return mockCloseWatcher; -} +}; const dispatchBackButtonEvent = () => { const ev = new Event('backbutton');