Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
80 changes: 44 additions & 36 deletions core/src/components/app/app.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<FocusVisibleUtility> | undefined;

@Component({
tag: 'ion-app',
styleUrl: 'app.scss',
Expand All @@ -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<FocusVisibleUtility> {
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);
Expand Down
92 changes: 92 additions & 0 deletions core/src/components/app/test/remount/app.e2e.ts
Original file line number Diff line number Diff line change
@@ -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<HTMLElement>('.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);
});
});
});
41 changes: 41 additions & 0 deletions core/src/components/app/test/remount/index.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>App - Remounting</title>
<link href="../../../../../css/ionic.bundle.css" rel="stylesheet" />
<link href="../../../../../scripts/testing/styles.css" rel="stylesheet" />
<script src="../../../../../scripts/testing/scripts.js"></script>
<script>
window.Ionic.config.hardwareBackButton =
new URLSearchParams(location.search).get('hardwareBackButton') !== 'false';
window.Ionic.config.inputShims = false;
window.Ionic.config.statusTap = false;
</script>
<script type="module" src="../../../../../dist/ionic/ionic.esm.js"></script>
</head>
<body>
<div id="apps">
<ion-app>
<ion-content>
<button
class="ion-activatable ion-activatable-instant ion-focusable"
style="position: relative; padding: 24px"
>
Tap for a ripple
<ion-ripple-effect></ion-ripple-effect>
</button>
<button onclick="remount()">Remount app</button>
</ion-content>
</ion-app>
</div>
<script>
const appMarkup = document.querySelector('ion-app').outerHTML;
if (new URLSearchParams(location.search).has('multiple')) {
document.querySelector('#apps').insertAdjacentHTML('beforeend', appMarkup);
}
window.remount = () => (document.querySelector('#apps').innerHTML = appMarkup);
</script>
</body>
</html>
Loading