From 13b441d09b95a701f16f20b156c799615bd8a0dc Mon Sep 17 00:00:00 2001 From: ShaneK Date: Mon, 5 Jan 2026 09:56:28 -0800 Subject: [PATCH 1/3] fix(tab-bar): prevent keyboard controller memory leak on rapid mount/unmount --- core/src/components/tab-bar/tab-bar.tsx | 18 ++- .../tab-bar/test/lifecycle/tab-bar.e2e.ts | 121 ++++++++++++++++++ .../components/tab-bar/test/tab-bar.spec.ts | 66 ++++++++++ 3 files changed, 204 insertions(+), 1 deletion(-) create mode 100644 core/src/components/tab-bar/test/lifecycle/tab-bar.e2e.ts create mode 100644 core/src/components/tab-bar/test/tab-bar.spec.ts diff --git a/core/src/components/tab-bar/tab-bar.tsx b/core/src/components/tab-bar/tab-bar.tsx index d17bb0ba145..1db3d733351 100644 --- a/core/src/components/tab-bar/tab-bar.tsx +++ b/core/src/components/tab-bar/tab-bar.tsx @@ -23,6 +23,7 @@ import type { TabBarChangedEventDetail } from './tab-bar-interface'; export class TabBar implements ComponentInterface { private keyboardCtrl: KeyboardController | null = null; private didLoad = false; + private isComponentConnected = false; @Element() el!: HTMLElement; @@ -88,7 +89,9 @@ export class TabBar implements ComponentInterface { } async connectedCallback() { - this.keyboardCtrl = await createKeyboardController(async (keyboardOpen, waitForResize) => { + this.isComponentConnected = true; + + const keyboardCtrl = await createKeyboardController(async (keyboardOpen, waitForResize) => { /** * If the keyboard is hiding, then we need to wait * for the webview to resize. Otherwise, the tab bar @@ -100,11 +103,24 @@ export class TabBar implements ComponentInterface { this.keyboardVisible = keyboardOpen; // trigger re-render by updating state }); + + /** + * Destroy the keyboard controller if the component was + * disconnected during async initialization to prevent memory leaks. + */ + if (this.isComponentConnected) { + this.keyboardCtrl = keyboardCtrl; + } else { + keyboardCtrl.destroy(); + } } disconnectedCallback() { + this.isComponentConnected = false; + if (this.keyboardCtrl) { this.keyboardCtrl.destroy(); + this.keyboardCtrl = null; } } diff --git a/core/src/components/tab-bar/test/lifecycle/tab-bar.e2e.ts b/core/src/components/tab-bar/test/lifecycle/tab-bar.e2e.ts new file mode 100644 index 00000000000..9d8af7977d4 --- /dev/null +++ b/core/src/components/tab-bar/test/lifecycle/tab-bar.e2e.ts @@ -0,0 +1,121 @@ +import { expect } from '@playwright/test'; +import { configs, test } from '@utils/test/playwright'; + +/** + * Tests for keyboard controller memory leak fix when tab-bar + * is rapidly mounted/unmounted. + */ +configs({ modes: ['ios'], directions: ['ltr'] }).forEach(({ title, config }) => { + test.describe(title('tab-bar: lifecycle'), () => { + test('should not error when rapidly mounting and unmounting', async ({ page }) => { + const errors: string[] = []; + page.on('console', (msg) => { + if (msg.type() === 'error') { + errors.push(msg.text()); + } + }); + + await page.setContent( + ` +
+ + `, + config + ); + + await page.waitForTimeout(500); + + expect(errors.length).toBe(0); + }); + + test('should cleanup keyboard controller when removed from DOM', async ({ page }, testInfo) => { + testInfo.annotations.push({ + type: 'issue', + description: 'IONIC-82', + }); + + const errors: string[] = []; + page.on('console', (msg) => { + if (msg.type() === 'error') { + errors.push(msg.text()); + } + }); + + await page.setContent( + ` +
+ + + Tab 1 + + +
+ `, + config + ); + + const tabBar = page.locator('#test-tab-bar'); + await expect(tabBar).toBeVisible(); + + await page.evaluate(() => { + document.getElementById('test-tab-bar')?.remove(); + }); + + await page.waitForTimeout(100); + + await expect(tabBar).not.toBeAttached(); + expect(errors.length).toBe(0); + }); + + test('should handle re-mounting after unmount', async ({ page }) => { + await page.setContent( + ` +
+ + `, + config + ); + + await page.waitForTimeout(500); + + const tabBar = page.locator('#remount-tab-bar'); + await expect(tabBar).toBeVisible(); + }); + }); +}); diff --git a/core/src/components/tab-bar/test/tab-bar.spec.ts b/core/src/components/tab-bar/test/tab-bar.spec.ts new file mode 100644 index 00000000000..2afea6b149e --- /dev/null +++ b/core/src/components/tab-bar/test/tab-bar.spec.ts @@ -0,0 +1,66 @@ +import { newSpecPage } from '@stencil/core/testing'; + +import { TabBar } from '../tab-bar'; +import { TabButton } from '../../tab-button/tab-button'; + +describe('tab-bar', () => { + describe('basic rendering', () => { + it('should render with tab buttons', async () => { + const page = await newSpecPage({ + components: [TabBar, TabButton], + html: ` + + + Tab 1 + + + `, + }); + + expect(page.root).toBeTruthy(); + expect(page.root?.tagName).toBe('ION-TAB-BAR'); + }); + + it('should have correct role attribute', async () => { + const page = await newSpecPage({ + components: [TabBar], + html: ``, + }); + + expect(page.root?.getAttribute('role')).toBe('tablist'); + }); + }); + + describe('lifecycle', () => { + it('should handle removal without errors', async () => { + const page = await newSpecPage({ + components: [TabBar], + html: ``, + }); + + page.root?.remove(); + await page.waitForChanges(); + + expect(page.body.querySelector('ion-tab-bar')).toBeNull(); + }); + + it('should handle rapid mount/unmount without errors', async () => { + const page = await newSpecPage({ + components: [TabBar], + html: `
`, + }); + + const container = page.body.querySelector('#container')!; + + for (let i = 0; i < 3; i++) { + const tabBar = page.doc.createElement('ion-tab-bar'); + container.appendChild(tabBar); + await page.waitForChanges(); + tabBar.remove(); + await page.waitForChanges(); + } + + expect(container.children.length).toBe(0); + }); + }); +}); From 6ea7cef8adecb7fbbb56f82a9e3147bddf3c9c2f Mon Sep 17 00:00:00 2001 From: ShaneK Date: Thu, 8 Jan 2026 10:05:05 -0800 Subject: [PATCH 2/3] fix(tab-bar): prevent keyboard controller memory leak on rapid mount/unmount --- core/src/components/tab-bar/tab-bar.tsx | 22 ++-- .../tab-bar/test/lifecycle/tab-bar.e2e.ts | 121 ------------------ .../components/tab-bar/test/tab-bar.spec.ts | 66 ---------- 3 files changed, 14 insertions(+), 195 deletions(-) delete mode 100644 core/src/components/tab-bar/test/lifecycle/tab-bar.e2e.ts delete mode 100644 core/src/components/tab-bar/test/tab-bar.spec.ts diff --git a/core/src/components/tab-bar/tab-bar.tsx b/core/src/components/tab-bar/tab-bar.tsx index 1db3d733351..d8a03cdb71c 100644 --- a/core/src/components/tab-bar/tab-bar.tsx +++ b/core/src/components/tab-bar/tab-bar.tsx @@ -22,8 +22,8 @@ import type { TabBarChangedEventDetail } from './tab-bar-interface'; }) export class TabBar implements ComponentInterface { private keyboardCtrl: KeyboardController | null = null; + private keyboardCtrlPromise: Promise | null = null; private didLoad = false; - private isComponentConnected = false; @Element() el!: HTMLElement; @@ -89,9 +89,7 @@ export class TabBar implements ComponentInterface { } async connectedCallback() { - this.isComponentConnected = true; - - const keyboardCtrl = await createKeyboardController(async (keyboardOpen, waitForResize) => { + const promise = createKeyboardController(async (keyboardOpen, waitForResize) => { /** * If the keyboard is hiding, then we need to wait * for the webview to resize. Otherwise, the tab bar @@ -103,20 +101,28 @@ export class TabBar implements ComponentInterface { this.keyboardVisible = keyboardOpen; // trigger re-render by updating state }); + this.keyboardCtrlPromise = promise; + + const keyboardCtrl = await promise; /** - * Destroy the keyboard controller if the component was - * disconnected during async initialization to prevent memory leaks. + * Only assign if this is still the current promise. + * Otherwise, a new connectedCallback has started or + * disconnectedCallback was called, so destroy this instance. */ - if (this.isComponentConnected) { + if (this.keyboardCtrlPromise === promise) { this.keyboardCtrl = keyboardCtrl; + this.keyboardCtrlPromise = null; } else { keyboardCtrl.destroy(); } } disconnectedCallback() { - this.isComponentConnected = false; + if (this.keyboardCtrlPromise) { + this.keyboardCtrlPromise.then((ctrl) => ctrl.destroy()); + this.keyboardCtrlPromise = null; + } if (this.keyboardCtrl) { this.keyboardCtrl.destroy(); diff --git a/core/src/components/tab-bar/test/lifecycle/tab-bar.e2e.ts b/core/src/components/tab-bar/test/lifecycle/tab-bar.e2e.ts deleted file mode 100644 index 9d8af7977d4..00000000000 --- a/core/src/components/tab-bar/test/lifecycle/tab-bar.e2e.ts +++ /dev/null @@ -1,121 +0,0 @@ -import { expect } from '@playwright/test'; -import { configs, test } from '@utils/test/playwright'; - -/** - * Tests for keyboard controller memory leak fix when tab-bar - * is rapidly mounted/unmounted. - */ -configs({ modes: ['ios'], directions: ['ltr'] }).forEach(({ title, config }) => { - test.describe(title('tab-bar: lifecycle'), () => { - test('should not error when rapidly mounting and unmounting', async ({ page }) => { - const errors: string[] = []; - page.on('console', (msg) => { - if (msg.type() === 'error') { - errors.push(msg.text()); - } - }); - - await page.setContent( - ` -
- - `, - config - ); - - await page.waitForTimeout(500); - - expect(errors.length).toBe(0); - }); - - test('should cleanup keyboard controller when removed from DOM', async ({ page }, testInfo) => { - testInfo.annotations.push({ - type: 'issue', - description: 'IONIC-82', - }); - - const errors: string[] = []; - page.on('console', (msg) => { - if (msg.type() === 'error') { - errors.push(msg.text()); - } - }); - - await page.setContent( - ` -
- - - Tab 1 - - -
- `, - config - ); - - const tabBar = page.locator('#test-tab-bar'); - await expect(tabBar).toBeVisible(); - - await page.evaluate(() => { - document.getElementById('test-tab-bar')?.remove(); - }); - - await page.waitForTimeout(100); - - await expect(tabBar).not.toBeAttached(); - expect(errors.length).toBe(0); - }); - - test('should handle re-mounting after unmount', async ({ page }) => { - await page.setContent( - ` -
- - `, - config - ); - - await page.waitForTimeout(500); - - const tabBar = page.locator('#remount-tab-bar'); - await expect(tabBar).toBeVisible(); - }); - }); -}); diff --git a/core/src/components/tab-bar/test/tab-bar.spec.ts b/core/src/components/tab-bar/test/tab-bar.spec.ts deleted file mode 100644 index 2afea6b149e..00000000000 --- a/core/src/components/tab-bar/test/tab-bar.spec.ts +++ /dev/null @@ -1,66 +0,0 @@ -import { newSpecPage } from '@stencil/core/testing'; - -import { TabBar } from '../tab-bar'; -import { TabButton } from '../../tab-button/tab-button'; - -describe('tab-bar', () => { - describe('basic rendering', () => { - it('should render with tab buttons', async () => { - const page = await newSpecPage({ - components: [TabBar, TabButton], - html: ` - - - Tab 1 - - - `, - }); - - expect(page.root).toBeTruthy(); - expect(page.root?.tagName).toBe('ION-TAB-BAR'); - }); - - it('should have correct role attribute', async () => { - const page = await newSpecPage({ - components: [TabBar], - html: ``, - }); - - expect(page.root?.getAttribute('role')).toBe('tablist'); - }); - }); - - describe('lifecycle', () => { - it('should handle removal without errors', async () => { - const page = await newSpecPage({ - components: [TabBar], - html: ``, - }); - - page.root?.remove(); - await page.waitForChanges(); - - expect(page.body.querySelector('ion-tab-bar')).toBeNull(); - }); - - it('should handle rapid mount/unmount without errors', async () => { - const page = await newSpecPage({ - components: [TabBar], - html: `
`, - }); - - const container = page.body.querySelector('#container')!; - - for (let i = 0; i < 3; i++) { - const tabBar = page.doc.createElement('ion-tab-bar'); - container.appendChild(tabBar); - await page.waitForChanges(); - tabBar.remove(); - await page.waitForChanges(); - } - - expect(container.children.length).toBe(0); - }); - }); -}); From 4d19d31aa8b92b47ffc987861d14b9506104fa35 Mon Sep 17 00:00:00 2001 From: ShaneK Date: Tue, 13 Jan 2026 14:24:36 -0800 Subject: [PATCH 3/3] fix(footer): fixing memory leak from rapid keyboard mount/unmount --- core/src/components/footer/footer.tsx | 24 +++++++++++++++++++++++- 1 file changed, 23 insertions(+), 1 deletion(-) diff --git a/core/src/components/footer/footer.tsx b/core/src/components/footer/footer.tsx index e857f9afda1..23d7e5d48ad 100644 --- a/core/src/components/footer/footer.tsx +++ b/core/src/components/footer/footer.tsx @@ -22,6 +22,7 @@ export class Footer implements ComponentInterface { private scrollEl?: HTMLElement; private contentScrollCallback?: () => void; private keyboardCtrl: KeyboardController | null = null; + private keyboardCtrlPromise: Promise | null = null; @State() private keyboardVisible = false; @@ -52,7 +53,7 @@ export class Footer implements ComponentInterface { } async connectedCallback() { - this.keyboardCtrl = await createKeyboardController(async (keyboardOpen, waitForResize) => { + const promise = createKeyboardController(async (keyboardOpen, waitForResize) => { /** * If the keyboard is hiding, then we need to wait * for the webview to resize. Otherwise, the footer @@ -64,11 +65,32 @@ export class Footer implements ComponentInterface { this.keyboardVisible = keyboardOpen; // trigger re-render by updating state }); + this.keyboardCtrlPromise = promise; + + const keyboardCtrl = await promise; + + /** + * Only assign if this is still the current promise. + * Otherwise, a new connectedCallback has started or + * disconnectedCallback was called, so destroy this instance. + */ + if (this.keyboardCtrlPromise === promise) { + this.keyboardCtrl = keyboardCtrl; + this.keyboardCtrlPromise = null; + } else { + keyboardCtrl.destroy(); + } } disconnectedCallback() { + if (this.keyboardCtrlPromise) { + this.keyboardCtrlPromise.then((ctrl) => ctrl.destroy()); + this.keyboardCtrlPromise = null; + } + if (this.keyboardCtrl) { this.keyboardCtrl.destroy(); + this.keyboardCtrl = null; } }