diff --git a/core/src/components/app/app.tsx b/core/src/components/app/app.tsx index c1c7e5366d9..204f28b38a3 100644 --- a/core/src/components/app/app.tsx +++ b/core/src/components/app/app.tsx @@ -8,6 +8,9 @@ import { isPlatform } from '@utils/platform'; import { config } from '../../global/config'; import { getIonMode } from '../../global/ionic-global'; +// These helpers listen on the document and must outlive individual app elements. +let appInitialization: Promise | undefined; + @Component({ tag: 'ion-app', styleUrl: 'app.scss', @@ -21,46 +24,51 @@ export class App implements ComponentInterface { componentDidLoad() { if (Build.isBrowser) { this.rIC(async () => { - const isHybrid = isPlatform(window, 'hybrid'); - if (!config.getBoolean('_testing')) { - import('../../utils/tap-click').then((module) => module.startTapClick(config)); - } - if (config.getBoolean('statusTap', isHybrid)) { - import('../../utils/status-tap').then((module) => module.startStatusTap()); - } - if (config.getBoolean('inputShims', needInputShims())) { - /** - * needInputShims() ensures that only iOS and Android - * platforms proceed into this block. - */ - const platform = isPlatform(window, 'ios') ? 'ios' : 'android'; - import('../../utils/input-shims/input-shims').then((module) => module.startInputShims(config, platform)); - } - const hardwareBackButtonModule = await import('../../utils/hardware-back-button'); - const supportsHardwareBackButtonEvents = isHybrid || shouldUseCloseWatcher(); - 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 (shouldUseCloseWatcher()) { - printIonWarning( - '[ion-app] - 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') { - import('../../utils/keyboard/keyboard').then((module) => module.startKeyboardAssist(window)); - } - import('../../utils/focus-visible').then((module) => (this.focusVisible = module.startFocusVisible())); + appInitialization ??= this.initialize(); + this.focusVisible = await appInitialization; }); } } + private async initialize(): Promise { + const isHybrid = isPlatform(window, 'hybrid'); + if (!config.getBoolean('_testing')) { + import('../../utils/tap-click').then((module) => module.startTapClick(config)); + } + if (config.getBoolean('statusTap', isHybrid)) { + import('../../utils/status-tap').then((module) => module.startStatusTap()); + } + if (config.getBoolean('inputShims', needInputShims())) { + /** + * needInputShims() ensures that only iOS and Android + * platforms proceed into this block. + */ + const platform = isPlatform(window, 'ios') ? 'ios' : 'android'; + import('../../utils/input-shims/input-shims').then((module) => module.startInputShims(config, platform)); + } + const hardwareBackButtonModule = await import('../../utils/hardware-back-button'); + const supportsHardwareBackButtonEvents = isHybrid || shouldUseCloseWatcher(); + 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 (shouldUseCloseWatcher()) { + printIonWarning( + '[ion-app] - 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') { + import('../../utils/keyboard/keyboard').then((module) => module.startKeyboardAssist(window)); + } + return (await import('../../utils/focus-visible')).startFocusVisible(); + } + disconnectedCallback() { if (this.loadTimeout) { clearTimeout(this.loadTimeout); diff --git a/core/src/components/app/test/remount/app.e2e.ts b/core/src/components/app/test/remount/app.e2e.ts new file mode 100644 index 00000000000..d6ff637db63 --- /dev/null +++ b/core/src/components/app/test/remount/app.e2e.ts @@ -0,0 +1,92 @@ +import { expect } from '@playwright/test'; +import type { Locator } from '@playwright/test'; +import { configs, test } from '@utils/test/playwright'; + +const waitForApp = async (app: Locator) => { + await expect + .poll(() => + app.evaluate(async (el: HTMLIonAppElement) => { + const button = el.querySelector('.ion-focusable')!; + await el.setFocus([button]); + return button.classList.contains('ion-focused'); + }) + ) + .toBe(true); +}; + +configs({ modes: ['md'], directions: ['ltr'] }).forEach(({ title, config }) => { + test.describe(title('app: remounting'), () => { + test.beforeEach(async ({ page }) => { + // Tap effects are intentionally disabled by the normal testing config. + await page.goto('/src/components/app/test/remount?ionic:_testing=false', config); + await waitForApp(page.locator('ion-app')); + }); + + test('should add one ripple after repeated remounts', async ({ page }) => { + for (let i = 0; i < 3; i++) { + await page.evaluate(() => (window as any).remount()); + await waitForApp(page.locator('ion-app')); + } + await page.locator('.ion-activatable').hover(); + await page.mouse.down(); + await expect(page.locator('ion-ripple-effect .ripple-effect')).toHaveCount(1); + await page.mouse.up(); + }); + + test('should dispatch one back-button event after repeated remounts', async ({ page }) => { + for (let i = 0; i < 3; i++) { + await page.evaluate(() => (window as any).remount()); + await waitForApp(page.locator('ion-app')); + } + const events = await page.evaluate(() => { + let count = 0; + document.addEventListener('ionBackButton', () => count++); + document.dispatchEvent(new Event('backbutton')); + return count; + }); + expect(events).toBe(1); + }); + + test('should keep helpers usable when apps are replaced in quick succession', async ({ page }) => { + await page.evaluate(() => { + (window as any).remount(); + (window as any).remount(); + }); + await waitForApp(page.locator('ion-app')); + const events = await page.evaluate(() => { + let count = 0; + document.addEventListener('ionBackButton', () => count++); + document.dispatchEvent(new Event('backbutton')); + return count; + }); + expect(events).toBe(1); + }); + + test('should share in-flight initialization and preserve setFocus on each app', async ({ page }) => { + await page.goto('/src/components/app/test/remount?ionic:_testing=false&multiple=true', config); + await waitForApp(page.locator('ion-app').first()); + await waitForApp(page.locator('ion-app').last()); + const events = await page.evaluate(() => { + let count = 0; + document.addEventListener('ionBackButton', () => count++); + document.dispatchEvent(new Event('backbutton')); + return count; + }); + expect(events).toBe(1); + }); + + test('should preserve the disabled hardware-back-button configuration', async ({ page }) => { + await page.goto('/src/components/app/test/remount?ionic:_testing=false&hardwareBackButton=false', config); + await waitForApp(page.locator('ion-app')); + await page.evaluate(() => (window as any).remount()); + await waitForApp(page.locator('ion-app')); + const events = await page.evaluate(() => { + let count = 0; + document.addEventListener('ionBackButton', () => count++); + document.dispatchEvent(new Event('backbutton')); + return count; + }); + expect(events).toBe(0); + }); + }); +}); diff --git a/core/src/components/app/test/remount/index.html b/core/src/components/app/test/remount/index.html new file mode 100644 index 00000000000..a33b9cc048e --- /dev/null +++ b/core/src/components/app/test/remount/index.html @@ -0,0 +1,41 @@ + + + + + + App - Remounting + + + + + + + +
+ + + + + + +
+ + +