fix(many): clear timeouts (#30851)

Issue number: resolves #30860

## What is the current behavior?
We have flaky tests in an ionic angular project that root cause are not
cleaned up timeouts.
I commented out the timeout in the searchbar componentWillLoad method.
and after several runs no flaky tests at all.

My guess -> test runs faster than the 300ms it takes til the timeout
runs. Everything is cleaned up, but not the ionic timeouts (i think i
saw something similar in other components)

## What is the new behavior?
Timeouts are cleaned on disconnect.

## Does this introduce a breaking change?

- [ ] Yes
- [x] No

## Other information

Testrunner is vitest + angular 20 and latest ionic version 8.x
This commit is contained in:
Bengt Weiße
2026-02-18 11:25:43 +01:00
committed by GitHub
parent 53172d1a40
commit 70b1237823
7 changed files with 72 additions and 15 deletions

View File

@@ -14,12 +14,13 @@ import { getIonMode } from '../../global/ionic-global';
})
export class App implements ComponentInterface {
private focusVisible?: FocusVisibleUtility;
private loadTimeout?: ReturnType<typeof setTimeout> | undefined;
@Element() el!: HTMLElement;
componentDidLoad() {
if (Build.isBrowser) {
rIC(async () => {
this.rIC(async () => {
const isHybrid = isPlatform(window, 'hybrid');
if (!config.getBoolean('_testing')) {
import('../../utils/tap-click').then((module) => module.startTapClick(config));
@@ -60,6 +61,12 @@ export class App implements ComponentInterface {
}
}
disconnectedCallback() {
if (this.loadTimeout) {
clearTimeout(this.loadTimeout);
}
}
/**
* Used to set focus on an element that uses `ion-focusable`.
* Do not use this if focusing the element as a result of a keyboard
@@ -78,6 +85,14 @@ export class App implements ComponentInterface {
}
}
private rIC(callback: () => void) {
if ('requestIdleCallback' in window) {
(window as any).requestIdleCallback(callback);
} else {
this.loadTimeout = setTimeout(callback, 32);
}
}
render() {
const mode = getIonMode(this);
return (
@@ -113,11 +128,3 @@ const needInputShims = () => {
return false;
};
const rIC = (callback: () => void) => {
if ('requestIdleCallback' in window) {
(window as any).requestIdleCallback(callback);
} else {
setTimeout(callback, 32);
}
};

View File

@@ -188,6 +188,11 @@ export class Content implements ComponentInterface {
this.tabsElement = null;
this.tabsLoadCallback = undefined;
}
if (this.resizeTimeout) {
clearTimeout(this.resizeTimeout);
this.resizeTimeout = null;
}
}
/**

View File

@@ -124,6 +124,7 @@ export class Datetime implements ComponentInterface {
private maxParts?: any;
private todayParts!: DatetimeParts;
private defaultParts!: DatetimeParts;
private loadTimeout: ReturnType<typeof setTimeout> | undefined;
private prevPresentation: string | null = null;
@@ -1077,6 +1078,9 @@ export class Datetime implements ComponentInterface {
this.clearFocusVisible();
this.clearFocusVisible = undefined;
}
if (this.loadTimeout) {
clearTimeout(this.loadTimeout);
}
}
/**
@@ -1175,7 +1179,7 @@ export class Datetime implements ComponentInterface {
*
* We schedule this after everything has had a chance to run.
*/
setTimeout(() => {
this.loadTimeout = setTimeout(() => {
this.ensureReadyIfVisible();
}, 100);

View File

@@ -10,6 +10,7 @@ import { getIonMode } from '../../global/ionic-global';
})
export class FabList implements ComponentInterface {
@Element() el!: HTMLIonFabElement;
private activateTimeouts: ReturnType<typeof setTimeout>[] = [];
/**
* If `true`, the fab list will show all fab buttons in the list.
@@ -18,12 +19,15 @@ export class FabList implements ComponentInterface {
@Watch('activated')
protected activatedChanged(activated: boolean) {
this.activateTimeouts.forEach(clearTimeout);
this.activateTimeouts = [];
const fabs = Array.from(this.el.querySelectorAll('ion-fab-button'));
// if showing the fabs add a timeout, else show immediately
const timeout = activated ? 30 : 0;
fabs.forEach((fab, i) => {
setTimeout(() => (fab.show = activated), i * timeout);
this.activateTimeouts.push(setTimeout(() => (fab.show = activated), i * timeout));
});
}
@@ -32,6 +36,11 @@ export class FabList implements ComponentInterface {
*/
@Prop() side: 'start' | 'end' | 'top' | 'bottom' = 'bottom';
disconnectedCallback() {
this.activateTimeouts.forEach(clearTimeout);
this.activateTimeouts = [];
}
render() {
const mode = getIonMode(this);
return (

View File

@@ -16,6 +16,7 @@ import { getIonMode } from '../../global/ionic-global';
export class Img implements ComponentInterface {
private io?: IntersectionObserver;
private inheritedAttributes: Attributes = {};
private loadTimeout: ReturnType<typeof setTimeout> | undefined;
@Element() el!: HTMLElement;
@@ -56,7 +57,17 @@ export class Img implements ComponentInterface {
this.addIO();
}
disconnectedCallback() {
if (this.loadTimeout) {
clearTimeout(this.loadTimeout);
}
}
private addIO() {
if (this.loadTimeout) {
clearTimeout(this.loadTimeout);
this.loadTimeout = undefined;
}
if (this.src === undefined) {
return;
}
@@ -82,7 +93,7 @@ export class Img implements ComponentInterface {
this.io.observe(this.el);
} else {
// fall back to setTimeout for Safari and IE
setTimeout(() => this.load(), 200);
this.loadTimeout = setTimeout(() => this.load(), 200);
}
}

View File

@@ -18,6 +18,7 @@ import type { Color, StyleEventDetail } from '../../interface';
})
export class Label implements ComponentInterface {
private inRange = false;
private loadTimeout: ReturnType<typeof setTimeout> | undefined;
@Element() el!: HTMLElement;
@@ -56,12 +57,18 @@ export class Label implements ComponentInterface {
componentDidLoad() {
if (this.noAnimate) {
setTimeout(() => {
this.loadTimeout = setTimeout(() => {
this.noAnimate = false;
}, 1000);
}
}
disconnectedCallback() {
if (this.loadTimeout) {
clearTimeout(this.loadTimeout);
}
}
@Watch('color')
colorChanged() {
this.emitColor();

View File

@@ -30,6 +30,8 @@ export class Searchbar implements ComponentInterface {
private originalIonInput?: EventEmitter<SearchbarInputEventDetail>;
private inputId = `ion-searchbar-${searchbarIds++}`;
private inheritedAttributes: Attributes = {};
private loadTimeout: ReturnType<typeof setTimeout> | undefined;
private clearTimeout: ReturnType<typeof setTimeout> | undefined;
/**
* The value of the input when the textarea is focused.
@@ -288,11 +290,20 @@ export class Searchbar implements ComponentInterface {
this.positionElements();
this.debounceChanged();
setTimeout(() => {
this.loadTimeout = setTimeout(() => {
this.noAnimate = false;
}, 300);
}
disconnectedCallback() {
if (this.loadTimeout) {
clearTimeout(this.loadTimeout);
}
if (this.clearTimeout) {
clearTimeout(this.clearTimeout);
}
}
private emitStyle() {
this.ionStyle.emit({
searchbar: true,
@@ -358,12 +369,15 @@ export class Searchbar implements ComponentInterface {
* Clears the input field and triggers the control change.
*/
private onClearInput = async (shouldFocus?: boolean) => {
if (this.clearTimeout) {
clearTimeout(this.clearTimeout);
}
this.ionClear.emit();
return new Promise<void>((resolve) => {
// setTimeout() fixes https://github.com/ionic-team/ionic-framework/issues/7527
// wait for 4 frames
setTimeout(() => {
this.clearTimeout = setTimeout(() => {
const value = this.getValue();
if (value !== '') {
this.value = '';