From be543c1b1ed100fbf3f2067f5adf2116ff625dc5 Mon Sep 17 00:00:00 2001 From: ionitron Date: Wed, 9 Jul 2025 20:16:22 +0000 Subject: [PATCH 01/13] chore(): update package lock files --- core/package-lock.json | 2 +- packages/angular-server/package-lock.json | 14 +++++------ packages/angular/package-lock.json | 14 +++++------ packages/docs/package-lock.json | 2 +- packages/react-router/package-lock.json | 30 +++++++++++------------ packages/react/package-lock.json | 14 +++++------ packages/vue-router/package-lock.json | 30 +++++++++++------------ packages/vue/package-lock.json | 14 +++++------ 8 files changed, 60 insertions(+), 60 deletions(-) diff --git a/core/package-lock.json b/core/package-lock.json index 32a9ad8f943..7f89cad0500 100644 --- a/core/package-lock.json +++ b/core/package-lock.json @@ -18337,4 +18337,4 @@ "dev": true } } -} \ No newline at end of file +} diff --git a/packages/angular-server/package-lock.json b/packages/angular-server/package-lock.json index f119acb6cd2..1d5d308d627 100644 --- a/packages/angular-server/package-lock.json +++ b/packages/angular-server/package-lock.json @@ -1031,9 +1031,9 @@ "dev": true }, "node_modules/@ionic/core": { - "version": "8.6.3", - "resolved": "https://registry.npmjs.org/@ionic/core/-/core-8.6.3.tgz", - "integrity": "sha512-N/mkw+sPecLEoO1lrnKDS0uZgl6PWSyFprCkkqoK1nHlfBkgFiHm5M9rvWlnGaFC/5xrhNGHdUtYHDFM+F8gRw==", + "version": "8.6.4", + "resolved": "https://registry.npmjs.org/@ionic/core/-/core-8.6.4.tgz", + "integrity": "sha512-6kOx0yQAkXkMvhe6fQPA034LgmCh4aL0nJ+GwzNMwLYAe2fVq6mRdM37jNldGiGIZ0Q9Te2sHTFTM/IGItuIyQ==", "license": "MIT", "dependencies": { "@stencil/core": "4.33.1", @@ -7305,9 +7305,9 @@ "dev": true }, "@ionic/core": { - "version": "8.6.3", - "resolved": "https://registry.npmjs.org/@ionic/core/-/core-8.6.3.tgz", - "integrity": "sha512-N/mkw+sPecLEoO1lrnKDS0uZgl6PWSyFprCkkqoK1nHlfBkgFiHm5M9rvWlnGaFC/5xrhNGHdUtYHDFM+F8gRw==", + "version": "8.6.4", + "resolved": "https://registry.npmjs.org/@ionic/core/-/core-8.6.4.tgz", + "integrity": "sha512-6kOx0yQAkXkMvhe6fQPA034LgmCh4aL0nJ+GwzNMwLYAe2fVq6mRdM37jNldGiGIZ0Q9Te2sHTFTM/IGItuIyQ==", "requires": { "@stencil/core": "4.33.1", "ionicons": "^7.2.2", @@ -11285,4 +11285,4 @@ } } } -} \ No newline at end of file +} diff --git a/packages/angular/package-lock.json b/packages/angular/package-lock.json index 81b2a91b17d..72858620884 100644 --- a/packages/angular/package-lock.json +++ b/packages/angular/package-lock.json @@ -1398,9 +1398,9 @@ "dev": true }, "node_modules/@ionic/core": { - "version": "8.6.3", - "resolved": "https://registry.npmjs.org/@ionic/core/-/core-8.6.3.tgz", - "integrity": "sha512-N/mkw+sPecLEoO1lrnKDS0uZgl6PWSyFprCkkqoK1nHlfBkgFiHm5M9rvWlnGaFC/5xrhNGHdUtYHDFM+F8gRw==", + "version": "8.6.4", + "resolved": "https://registry.npmjs.org/@ionic/core/-/core-8.6.4.tgz", + "integrity": "sha512-6kOx0yQAkXkMvhe6fQPA034LgmCh4aL0nJ+GwzNMwLYAe2fVq6mRdM37jNldGiGIZ0Q9Te2sHTFTM/IGItuIyQ==", "license": "MIT", "dependencies": { "@stencil/core": "4.33.1", @@ -9936,9 +9936,9 @@ "dev": true }, "@ionic/core": { - "version": "8.6.3", - "resolved": "https://registry.npmjs.org/@ionic/core/-/core-8.6.3.tgz", - "integrity": "sha512-N/mkw+sPecLEoO1lrnKDS0uZgl6PWSyFprCkkqoK1nHlfBkgFiHm5M9rvWlnGaFC/5xrhNGHdUtYHDFM+F8gRw==", + "version": "8.6.4", + "resolved": "https://registry.npmjs.org/@ionic/core/-/core-8.6.4.tgz", + "integrity": "sha512-6kOx0yQAkXkMvhe6fQPA034LgmCh4aL0nJ+GwzNMwLYAe2fVq6mRdM37jNldGiGIZ0Q9Te2sHTFTM/IGItuIyQ==", "requires": { "@stencil/core": "4.33.1", "ionicons": "^7.2.2", @@ -15194,4 +15194,4 @@ } } } -} \ No newline at end of file +} diff --git a/packages/docs/package-lock.json b/packages/docs/package-lock.json index e20de7cf5db..da0689b4eb4 100644 --- a/packages/docs/package-lock.json +++ b/packages/docs/package-lock.json @@ -10,4 +10,4 @@ "license": "MIT" } } -} \ No newline at end of file +} diff --git a/packages/react-router/package-lock.json b/packages/react-router/package-lock.json index 6e45ddede37..596ad244e08 100644 --- a/packages/react-router/package-lock.json +++ b/packages/react-router/package-lock.json @@ -238,9 +238,9 @@ "dev": true }, "node_modules/@ionic/core": { - "version": "8.6.3", - "resolved": "https://registry.npmjs.org/@ionic/core/-/core-8.6.3.tgz", - "integrity": "sha512-N/mkw+sPecLEoO1lrnKDS0uZgl6PWSyFprCkkqoK1nHlfBkgFiHm5M9rvWlnGaFC/5xrhNGHdUtYHDFM+F8gRw==", + "version": "8.6.4", + "resolved": "https://registry.npmjs.org/@ionic/core/-/core-8.6.4.tgz", + "integrity": "sha512-6kOx0yQAkXkMvhe6fQPA034LgmCh4aL0nJ+GwzNMwLYAe2fVq6mRdM37jNldGiGIZ0Q9Te2sHTFTM/IGItuIyQ==", "license": "MIT", "dependencies": { "@stencil/core": "4.33.1", @@ -415,12 +415,12 @@ } }, "node_modules/@ionic/react": { - "version": "8.6.3", - "resolved": "https://registry.npmjs.org/@ionic/react/-/react-8.6.3.tgz", - "integrity": "sha512-wBFn6cOKuRKJfUNBz1SyexLkqs+QdaSImEJJ5wepaIF5A94rKlG0JQGCCZjT0KaLbJ+UaQuCgRRQWUrT0XJKDQ==", + "version": "8.6.4", + "resolved": "https://registry.npmjs.org/@ionic/react/-/react-8.6.4.tgz", + "integrity": "sha512-X2jIi4TN/u9hlsy/BrubyJbIZ4Pn8cnbBFu/emQ1y7VH0rpVVWPgeHb8cKMJPNbKzszuvO+f5huGliNIYFIQ8A==", "license": "MIT", "dependencies": { - "@ionic/core": "8.6.3", + "@ionic/core": "8.6.4", "ionicons": "^7.0.0", "tslib": "*" }, @@ -4175,9 +4175,9 @@ "dev": true }, "@ionic/core": { - "version": "8.6.3", - "resolved": "https://registry.npmjs.org/@ionic/core/-/core-8.6.3.tgz", - "integrity": "sha512-N/mkw+sPecLEoO1lrnKDS0uZgl6PWSyFprCkkqoK1nHlfBkgFiHm5M9rvWlnGaFC/5xrhNGHdUtYHDFM+F8gRw==", + "version": "8.6.4", + "resolved": "https://registry.npmjs.org/@ionic/core/-/core-8.6.4.tgz", + "integrity": "sha512-6kOx0yQAkXkMvhe6fQPA034LgmCh4aL0nJ+GwzNMwLYAe2fVq6mRdM37jNldGiGIZ0Q9Te2sHTFTM/IGItuIyQ==", "requires": { "@stencil/core": "4.33.1", "ionicons": "^7.2.2", @@ -4281,11 +4281,11 @@ "requires": {} }, "@ionic/react": { - "version": "8.6.3", - "resolved": "https://registry.npmjs.org/@ionic/react/-/react-8.6.3.tgz", - "integrity": "sha512-wBFn6cOKuRKJfUNBz1SyexLkqs+QdaSImEJJ5wepaIF5A94rKlG0JQGCCZjT0KaLbJ+UaQuCgRRQWUrT0XJKDQ==", + "version": "8.6.4", + "resolved": "https://registry.npmjs.org/@ionic/react/-/react-8.6.4.tgz", + "integrity": "sha512-X2jIi4TN/u9hlsy/BrubyJbIZ4Pn8cnbBFu/emQ1y7VH0rpVVWPgeHb8cKMJPNbKzszuvO+f5huGliNIYFIQ8A==", "requires": { - "@ionic/core": "8.6.3", + "@ionic/core": "8.6.4", "ionicons": "^7.0.0", "tslib": "*" } @@ -6844,4 +6844,4 @@ "dev": true } } -} \ No newline at end of file +} diff --git a/packages/react/package-lock.json b/packages/react/package-lock.json index 664817d3396..73be803f045 100644 --- a/packages/react/package-lock.json +++ b/packages/react/package-lock.json @@ -736,9 +736,9 @@ "dev": true }, "node_modules/@ionic/core": { - "version": "8.6.3", - "resolved": "https://registry.npmjs.org/@ionic/core/-/core-8.6.3.tgz", - "integrity": "sha512-N/mkw+sPecLEoO1lrnKDS0uZgl6PWSyFprCkkqoK1nHlfBkgFiHm5M9rvWlnGaFC/5xrhNGHdUtYHDFM+F8gRw==", + "version": "8.6.4", + "resolved": "https://registry.npmjs.org/@ionic/core/-/core-8.6.4.tgz", + "integrity": "sha512-6kOx0yQAkXkMvhe6fQPA034LgmCh4aL0nJ+GwzNMwLYAe2fVq6mRdM37jNldGiGIZ0Q9Te2sHTFTM/IGItuIyQ==", "license": "MIT", "dependencies": { "@stencil/core": "4.33.1", @@ -12431,9 +12431,9 @@ "dev": true }, "@ionic/core": { - "version": "8.6.3", - "resolved": "https://registry.npmjs.org/@ionic/core/-/core-8.6.3.tgz", - "integrity": "sha512-N/mkw+sPecLEoO1lrnKDS0uZgl6PWSyFprCkkqoK1nHlfBkgFiHm5M9rvWlnGaFC/5xrhNGHdUtYHDFM+F8gRw==", + "version": "8.6.4", + "resolved": "https://registry.npmjs.org/@ionic/core/-/core-8.6.4.tgz", + "integrity": "sha512-6kOx0yQAkXkMvhe6fQPA034LgmCh4aL0nJ+GwzNMwLYAe2fVq6mRdM37jNldGiGIZ0Q9Te2sHTFTM/IGItuIyQ==", "requires": { "@stencil/core": "4.33.1", "ionicons": "^7.2.2", @@ -20674,4 +20674,4 @@ "dev": true } } -} \ No newline at end of file +} diff --git a/packages/vue-router/package-lock.json b/packages/vue-router/package-lock.json index f1270c27052..6b2b1b240da 100644 --- a/packages/vue-router/package-lock.json +++ b/packages/vue-router/package-lock.json @@ -673,9 +673,9 @@ "dev": true }, "node_modules/@ionic/core": { - "version": "8.6.3", - "resolved": "https://registry.npmjs.org/@ionic/core/-/core-8.6.3.tgz", - "integrity": "sha512-N/mkw+sPecLEoO1lrnKDS0uZgl6PWSyFprCkkqoK1nHlfBkgFiHm5M9rvWlnGaFC/5xrhNGHdUtYHDFM+F8gRw==", + "version": "8.6.4", + "resolved": "https://registry.npmjs.org/@ionic/core/-/core-8.6.4.tgz", + "integrity": "sha512-6kOx0yQAkXkMvhe6fQPA034LgmCh4aL0nJ+GwzNMwLYAe2fVq6mRdM37jNldGiGIZ0Q9Te2sHTFTM/IGItuIyQ==", "license": "MIT", "dependencies": { "@stencil/core": "4.33.1", @@ -865,12 +865,12 @@ } }, "node_modules/@ionic/vue": { - "version": "8.6.3", - "resolved": "https://registry.npmjs.org/@ionic/vue/-/vue-8.6.3.tgz", - "integrity": "sha512-vQb0lMs3TKbcEZQz1SF7E4TzZf0wRf3elJaIFd0PRa4+Shcn5zpliid8uCJTlPY5k943axIrPNxKaQPJFQXdrw==", + "version": "8.6.4", + "resolved": "https://registry.npmjs.org/@ionic/vue/-/vue-8.6.4.tgz", + "integrity": "sha512-vhFxCUk2hwPbJS1uTcZkVFB+9eFfzeis5TyL1mDmlULFhbGI/YTLTcWcXWSdG/myg4yPeb8brObWpMq36StJVw==", "license": "MIT", "dependencies": { - "@ionic/core": "8.6.3", + "@ionic/core": "8.6.4", "@stencil/vue-output-target": "0.10.7", "ionicons": "^7.0.0" } @@ -8041,9 +8041,9 @@ "dev": true }, "@ionic/core": { - "version": "8.6.3", - "resolved": "https://registry.npmjs.org/@ionic/core/-/core-8.6.3.tgz", - "integrity": "sha512-N/mkw+sPecLEoO1lrnKDS0uZgl6PWSyFprCkkqoK1nHlfBkgFiHm5M9rvWlnGaFC/5xrhNGHdUtYHDFM+F8gRw==", + "version": "8.6.4", + "resolved": "https://registry.npmjs.org/@ionic/core/-/core-8.6.4.tgz", + "integrity": "sha512-6kOx0yQAkXkMvhe6fQPA034LgmCh4aL0nJ+GwzNMwLYAe2fVq6mRdM37jNldGiGIZ0Q9Te2sHTFTM/IGItuIyQ==", "requires": { "@stencil/core": "4.33.1", "ionicons": "^7.2.2", @@ -8156,11 +8156,11 @@ "requires": {} }, "@ionic/vue": { - "version": "8.6.3", - "resolved": "https://registry.npmjs.org/@ionic/vue/-/vue-8.6.3.tgz", - "integrity": "sha512-vQb0lMs3TKbcEZQz1SF7E4TzZf0wRf3elJaIFd0PRa4+Shcn5zpliid8uCJTlPY5k943axIrPNxKaQPJFQXdrw==", + "version": "8.6.4", + "resolved": "https://registry.npmjs.org/@ionic/vue/-/vue-8.6.4.tgz", + "integrity": "sha512-vhFxCUk2hwPbJS1uTcZkVFB+9eFfzeis5TyL1mDmlULFhbGI/YTLTcWcXWSdG/myg4yPeb8brObWpMq36StJVw==", "requires": { - "@ionic/core": "8.6.3", + "@ionic/core": "8.6.4", "@stencil/vue-output-target": "0.10.7", "ionicons": "^7.0.0" } @@ -12991,4 +12991,4 @@ "dev": true } } -} \ No newline at end of file +} diff --git a/packages/vue/package-lock.json b/packages/vue/package-lock.json index 58935da227b..ee18699d73c 100644 --- a/packages/vue/package-lock.json +++ b/packages/vue/package-lock.json @@ -222,9 +222,9 @@ "dev": true }, "node_modules/@ionic/core": { - "version": "8.6.3", - "resolved": "https://registry.npmjs.org/@ionic/core/-/core-8.6.3.tgz", - "integrity": "sha512-N/mkw+sPecLEoO1lrnKDS0uZgl6PWSyFprCkkqoK1nHlfBkgFiHm5M9rvWlnGaFC/5xrhNGHdUtYHDFM+F8gRw==", + "version": "8.6.4", + "resolved": "https://registry.npmjs.org/@ionic/core/-/core-8.6.4.tgz", + "integrity": "sha512-6kOx0yQAkXkMvhe6fQPA034LgmCh4aL0nJ+GwzNMwLYAe2fVq6mRdM37jNldGiGIZ0Q9Te2sHTFTM/IGItuIyQ==", "license": "MIT", "dependencies": { "@stencil/core": "4.33.1", @@ -4167,9 +4167,9 @@ "dev": true }, "@ionic/core": { - "version": "8.6.3", - "resolved": "https://registry.npmjs.org/@ionic/core/-/core-8.6.3.tgz", - "integrity": "sha512-N/mkw+sPecLEoO1lrnKDS0uZgl6PWSyFprCkkqoK1nHlfBkgFiHm5M9rvWlnGaFC/5xrhNGHdUtYHDFM+F8gRw==", + "version": "8.6.4", + "resolved": "https://registry.npmjs.org/@ionic/core/-/core-8.6.4.tgz", + "integrity": "sha512-6kOx0yQAkXkMvhe6fQPA034LgmCh4aL0nJ+GwzNMwLYAe2fVq6mRdM37jNldGiGIZ0Q9Te2sHTFTM/IGItuIyQ==", "requires": { "@stencil/core": "4.33.1", "ionicons": "^7.2.2", @@ -6819,4 +6819,4 @@ "dev": true } } -} \ No newline at end of file +} From f1defba2acb417c6f243b2902923d85efbb6f879 Mon Sep 17 00:00:00 2001 From: Colin Bares Date: Wed, 9 Jul 2025 15:53:48 -0500 Subject: [PATCH 02/13] fix(input): prevent layout shift when hiding password toggle (#30533) Issue number: resolves #29562 --------- ## What is the current behavior? When an input with a password toggle is given `disabled` or `readonly`, hiding the password toggle causes a layout shift as it shrinks the height of the input component. ## What is the new behavior? - Password toggle is given `visibility: hidden` instead of removing it from the DOM with `display: none` so it retains it's space but is still hidden and still removed from the accessibility tree. ## Does this introduce a breaking change? - [ ] Yes - [x] No ## Other information This solution was suggested by @piotr-cz in the bug report. --------- Co-authored-by: Brandy Smith <6577830+brandyscarney@users.noreply.github.com> --- core/src/components/input/input.scss | 2 +- .../components/input/test/states/input.e2e.ts | 64 +++++++++++++++++++ 2 files changed, 65 insertions(+), 1 deletion(-) diff --git a/core/src/components/input/input.scss b/core/src/components/input/input.scss index 57d438eb2d9..2161cc3dfb5 100644 --- a/core/src/components/input/input.scss +++ b/core/src/components/input/input.scss @@ -618,5 +618,5 @@ */ :host([disabled]) ::slotted(ion-input-password-toggle), :host([readonly]) ::slotted(ion-input-password-toggle) { - display: none; + visibility: hidden; } diff --git a/core/src/components/input/test/states/input.e2e.ts b/core/src/components/input/test/states/input.e2e.ts index eb51f760966..33d33d91cf0 100644 --- a/core/src/components/input/test/states/input.e2e.ts +++ b/core/src/components/input/test/states/input.e2e.ts @@ -26,5 +26,69 @@ configs({ directions: ['ltr'] }).forEach(({ title, screenshot, config }) => { const input = page.locator('ion-input'); await expect(input).toHaveScreenshot(screenshot(`input-disabled`)); }); + + test('should maintain consistent height when password toggle is hidden on disabled input', async ({ + page, + }, testInfo) => { + testInfo.annotations.push({ + type: 'issue', + description: 'https://github.com/ionic-team/ionic-framework/issues/29562', + }); + await page.setContent( + ` + + + + `, + config + ); + + const input = page.locator('ion-input'); + + // Get the height when input is enabled + const enabledHeight = await input.boundingBox().then((box) => box?.height); + + // Disable the input + await input.evaluate((el) => el.setAttribute('disabled', 'true')); + await page.waitForChanges(); + + // Get the height when input is disabled + const disabledHeight = await input.boundingBox().then((box) => box?.height); + + // Verify heights are the same + expect(enabledHeight).toBe(disabledHeight); + }); + + test('should maintain consistent height when password toggle is hidden on readonly input', async ({ + page, + }, testInfo) => { + testInfo.annotations.push({ + type: 'issue', + description: 'https://github.com/ionic-team/ionic-framework/issues/29562', + }); + await page.setContent( + ` + + + + `, + config + ); + + const input = page.locator('ion-input'); + + // Get the height when input is enabled + const enabledHeight = await input.boundingBox().then((box) => box?.height); + + // Make the input readonly + await input.evaluate((el) => el.setAttribute('readonly', 'true')); + await page.waitForChanges(); + + // Get the height when input is readonly + const readonlyHeight = await input.boundingBox().then((box) => box?.height); + + // Verify heights are the same + expect(enabledHeight).toBe(readonlyHeight); + }); }); }); From 8bfd6d903e5e9c398deca7e70e600ad3808f945a Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Fri, 11 Jul 2025 10:46:16 -0400 Subject: [PATCH 03/13] chore(deps): update dependency @capacitor/core to v7.4.2 (#30538) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This PR contains the following updates: | Package | Change | Age | Confidence | |---|---|---|---| | [@capacitor/core](https://capacitorjs.com) ([source](https://redirect.github.com/ionic-team/capacitor)) | [`7.4.1` -> `7.4.2`](https://renovatebot.com/diffs/npm/@capacitor%2fcore/7.4.1/7.4.2) | [![age](https://developer.mend.io/api/mc/badges/age/npm/@capacitor%2fcore/7.4.2?slim=true)](https://docs.renovatebot.com/merge-confidence/) | [![confidence](https://developer.mend.io/api/mc/badges/confidence/npm/@capacitor%2fcore/7.4.1/7.4.2?slim=true)](https://docs.renovatebot.com/merge-confidence/) | --- ### Release Notes
ionic-team/capacitor (@​capacitor/core) ### [`v7.4.2`](https://redirect.github.com/ionic-team/capacitor/blob/HEAD/CHANGELOG.md#742-2025-07-10) [Compare Source](https://redirect.github.com/ionic-team/capacitor/compare/7.4.1...7.4.2) ##### Bug Fixes - **android:** consider display cutout area for insets ([#​8042](https://redirect.github.com/ionic-team/capacitor/issues/8042)) ([b478211](https://redirect.github.com/ionic-team/capacitor/commit/b4782116856c35e3fb567393f10a36ce4632b44c)) - **http:** Properly URL-encode key and values during `x-www-form-urlencoded` POSTs ([#​8037](https://redirect.github.com/ionic-team/capacitor/issues/8037)) ([87b4641](https://redirect.github.com/ionic-team/capacitor/commit/87b4641d1fa32b78e6fc2e87ee7b2c49b625b213))
--- ### Configuration 📅 **Schedule**: Branch creation - "every weekday before 11am" (UTC), Automerge - At any time (no schedule defined). 🚦 **Automerge**: Disabled by config. Please merge this manually once you are satisfied. ♻ **Rebasing**: Never, or you tick the rebase/retry checkbox. 🔕 **Ignore**: Close this PR and you won't be reminded about this update again. --- - [ ] If you want to rebase/retry this PR, check this box --- This PR was generated by [Mend Renovate](https://mend.io/renovate/). View the [repository job log](https://developer.mend.io/github/ionic-team/ionic-framework). Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- core/package-lock.json | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/core/package-lock.json b/core/package-lock.json index 7f89cad0500..1cd139766a1 100644 --- a/core/package-lock.json +++ b/core/package-lock.json @@ -663,9 +663,9 @@ "dev": true }, "node_modules/@capacitor/core": { - "version": "7.4.1", - "resolved": "https://registry.npmjs.org/@capacitor/core/-/core-7.4.1.tgz", - "integrity": "sha512-0ap4FzPJItaeg3QiiS1WguPNHY2aD67fQ9wr7DojCRzTFuNXQPvFB6lBkqlrVeQyJ9jCw0KV/LXv25oXjDcsyA==", + "version": "7.4.2", + "resolved": "https://registry.npmjs.org/@capacitor/core/-/core-7.4.2.tgz", + "integrity": "sha512-akCf9A1FUR8AWTtmgGjHEq6LmGsjA2U7igaJ9PxiCBfyxKqlDbuGHrlNdpvHEjV5tUPH3KYtkze6gtFcNKPU9A==", "dev": true, "dependencies": { "tslib": "^2.1.0" @@ -11101,9 +11101,9 @@ "dev": true }, "@capacitor/core": { - "version": "7.4.1", - "resolved": "https://registry.npmjs.org/@capacitor/core/-/core-7.4.1.tgz", - "integrity": "sha512-0ap4FzPJItaeg3QiiS1WguPNHY2aD67fQ9wr7DojCRzTFuNXQPvFB6lBkqlrVeQyJ9jCw0KV/LXv25oXjDcsyA==", + "version": "7.4.2", + "resolved": "https://registry.npmjs.org/@capacitor/core/-/core-7.4.2.tgz", + "integrity": "sha512-akCf9A1FUR8AWTtmgGjHEq6LmGsjA2U7igaJ9PxiCBfyxKqlDbuGHrlNdpvHEjV5tUPH3KYtkze6gtFcNKPU9A==", "dev": true, "requires": { "tslib": "^2.1.0" From 9b0099f462fda6d40b49dde1a1c97afbbbee2287 Mon Sep 17 00:00:00 2001 From: Shane Date: Fri, 11 Jul 2025 12:01:59 -0700 Subject: [PATCH 04/13] fix(modal): dismiss child modals when parent is dismissed (#30540) Issue number: resolves #30389 --------- ## What is the current behavior? Currently, when a child modal is present and a parent modal is somehow dismissed, the child modal stays open. This can cause issues in some frameworks like React and Angular, where this cuts the connection to the child modal and it can no longer be dismissed programmatically. ## What is the new behavior? This change enables modals to identify their children and close the children when they're closed. This prevents orphaned modals that can cause a poor UX. Note: This fix is only for inline modals, which is the biggest cause of the above issue. ## Does this introduce a breaking change? - [ ] Yes - [X] No ## Other information [Relevant test page](https://ionic-framework-git-fw-6597-ionic1.vercel.app/src/components/modal/test/inline) **Current dev build**: ``` 8.6.5-dev.11752242329.17d249a3 ``` --- core/src/components/modal/modal.tsx | 37 ++++++++++- .../components/modal/test/inline/index.html | 63 ++++++++++++++++--- .../components/modal/test/inline/modal.e2e.ts | 63 ++++++++++++++++++- 3 files changed, 153 insertions(+), 10 deletions(-) diff --git a/core/src/components/modal/modal.tsx b/core/src/components/modal/modal.tsx index 8f528e658e5..e5e906204f8 100644 --- a/core/src/components/modal/modal.tsx +++ b/core/src/components/modal/modal.tsx @@ -784,6 +784,13 @@ export class Modal implements ComponentInterface, OverlayInterface { */ const unlock = await this.lockController.lock(); + /** + * Dismiss all child modals. This is especially important in + * Angular and React because it's possible to lose control of a child + * modal when the parent modal is dismissed. + */ + await this.dismissNestedModals(); + /** * If a canDismiss handler is responsible * for calling the dismiss method, we should @@ -1115,6 +1122,34 @@ export class Modal implements ComponentInterface, OverlayInterface { } } + /** + * When the slot changes, we need to find all the modals in the slot + * and set the data-parent-ion-modal attribute on them so we can find them + * and dismiss them when we get dismissed. + * We need to do it this way because when a modal is opened, it's moved to + * the end of the body and is no longer an actual child of the modal. + */ + private onSlotChange = ({ target }: Event) => { + const slot = target as HTMLSlotElement; + slot.assignedElements().forEach((el) => { + el.querySelectorAll('ion-modal').forEach((childModal) => { + // We don't need to write to the DOM if the modal is already tagged + // If this is a deeply nested modal, this effect should cascade so we don't + // need to worry about another modal claiming the same child. + if (childModal.getAttribute('data-parent-ion-modal') === null) { + childModal.setAttribute('data-parent-ion-modal', this.el.id); + } + }); + }); + }; + + private async dismissNestedModals(): Promise { + const nestedModals = document.querySelectorAll(`ion-modal[data-parent-ion-modal="${this.el.id}"]`); + nestedModals?.forEach(async (modal) => { + await (modal as HTMLIonModalElement).dismiss(undefined, 'parent-dismissed'); + }); + } + render() { const { handle, @@ -1192,7 +1227,7 @@ export class Modal implements ComponentInterface, OverlayInterface { ref={(el) => (this.dragHandleEl = el)} > )} - + ); diff --git a/core/src/components/modal/test/inline/index.html b/core/src/components/modal/test/inline/index.html index 726b682bd86..2e29f756b93 100644 --- a/core/src/components/modal/test/inline/index.html +++ b/core/src/components/modal/test/inline/index.html @@ -24,29 +24,76 @@ - - - - Modal - - - This is my inline modal content! - + diff --git a/core/src/components/modal/test/inline/modal.e2e.ts b/core/src/components/modal/test/inline/modal.e2e.ts index 05276722d95..35690fc2d8f 100644 --- a/core/src/components/modal/test/inline/modal.e2e.ts +++ b/core/src/components/modal/test/inline/modal.e2e.ts @@ -7,7 +7,7 @@ configs({ modes: ['ios'], directions: ['ltr'] }).forEach(({ title, config }) => await page.goto('/src/components/modal/test/inline', config); const ionModalDidPresent = await page.spyOnEvent('ionModalDidPresent'); const ionModalDidDismiss = await page.spyOnEvent('ionModalDidDismiss'); - const modal = page.locator('ion-modal'); + const modal = page.locator('ion-modal').first(); await page.click('#open-inline-modal'); @@ -22,6 +22,67 @@ configs({ modes: ['ios'], directions: ['ltr'] }).forEach(({ title, config }) => await expect(modal).toBeHidden(); }); + test('it should dismiss child modals when parent modal is dismissed', async ({ page }) => { + await page.goto('/src/components/modal/test/inline', config); + const ionModalDidPresent = await page.spyOnEvent('ionModalDidPresent'); + const ionModalDidDismiss = await page.spyOnEvent('ionModalDidDismiss'); + + const parentModal = page.locator('ion-modal').first(); + const childModal = page.locator('#child-modal'); + + // Open the parent modal + await page.click('#open-inline-modal'); + await ionModalDidPresent.next(); + await expect(parentModal).toBeVisible(); + + // Open the child modal + await page.click('#open-child-modal'); + await ionModalDidPresent.next(); + await expect(childModal).toBeVisible(); + + // Both modals should be visible + await expect(parentModal).toBeVisible(); + await expect(childModal).toBeVisible(); + + // Dismiss the parent modal + await page.click('#dismiss-parent'); + + // Wait for both modals to be dismissed + await ionModalDidDismiss.next(); // child modal dismissed + await ionModalDidDismiss.next(); // parent modal dismissed + + // Both modals should be hidden + await expect(parentModal).toBeHidden(); + await expect(childModal).toBeHidden(); + }); + + test('it should only dismiss child modal when child dismiss button is clicked', async ({ page }) => { + await page.goto('/src/components/modal/test/inline', config); + const ionModalDidPresent = await page.spyOnEvent('ionModalDidPresent'); + const ionModalDidDismiss = await page.spyOnEvent('ionModalDidDismiss'); + + const parentModal = page.locator('ion-modal').first(); + const childModal = page.locator('#child-modal'); + + // Open the parent modal + await page.click('#open-inline-modal'); + await ionModalDidPresent.next(); + await expect(parentModal).toBeVisible(); + + // Open the child modal + await page.click('#open-child-modal'); + await ionModalDidPresent.next(); + await expect(childModal).toBeVisible(); + + // Dismiss only the child modal + await page.click('#dismiss-child'); + await ionModalDidDismiss.next(); + + // Parent modal should still be visible, child modal should be hidden + await expect(parentModal).toBeVisible(); + await expect(childModal).toBeHidden(); + }); + test('presenting should create a single root element with the ion-page class', async ({ page }, testInfo) => { testInfo.annotations.push({ type: 'issue', From 8b4023d520212c254395a5be6d3a76dcbee6f2da Mon Sep 17 00:00:00 2001 From: Brandy Smith Date: Fri, 11 Jul 2025 15:19:50 -0400 Subject: [PATCH 05/13] fix(input-otp): improve autofill detection and invalid character handling (#30541) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Issue number: resolves #30459 --------- ## What is the current behavior? 1. Typing `"12345"` in a 4-box input-otp with `type="text"` is incorrectly triggering autofill detection on Android, causing `"45"` to be distributed across the first two boxes instead of replacing the `"4"` with the `"5"`. **Current Behavior**: Type `"12345"` → `["4", "5", "", ""]` (incorrectly distributed) **Expected Behavior**: Type `"12345"` → `["1", "2", "3", "5"]` (correctly replaces last character) 2. Typing an invalid character (like `"w"` when `type="number"`) in an input box with a value is inserted, ignoring the input validation. **Current Behavior**: Type `"2"` in the first box, focus it again, type `"w"` → `"2w"` appears **Expected Behavior**: Type `"2"` in the first box, focus it again, type `"w"` → `"2"` remains (invalid character rejected) ## What is the new behavior? - Fixes autofill detection on Android devices - Fixes invalid character insertion in filled input boxes - Improves cursor position handling when typing in a filled box - Adds e2e tests for more coverage ## Does this introduce a breaking change? - [ ] Yes - [x] No ## Other information Dev build: `8.6.5-dev.11752245814.1253279a` --------- Co-authored-by: Brandy Smith <6577830+brandyscarney@users.noreply.github.com> --- core/src/components/input-otp/input-otp.tsx | 151 ++++++++++++------ .../input-otp/test/basic/input-otp.e2e.ts | 108 +++++++++++++ 2 files changed, 210 insertions(+), 49 deletions(-) diff --git a/core/src/components/input-otp/input-otp.tsx b/core/src/components/input-otp/input-otp.tsx index 3e6cc3855b2..a93eabd926d 100644 --- a/core/src/components/input-otp/input-otp.tsx +++ b/core/src/components/input-otp/input-otp.tsx @@ -48,6 +48,7 @@ export class InputOTP implements ComponentInterface { @State() private inputValues: string[] = []; @State() hasFocus = false; + @State() private previousInputValues: string[] = []; /** * Indicates whether and how the text value should be automatically capitalized as it is entered/edited by the user. @@ -336,6 +337,7 @@ export class InputOTP implements ComponentInterface { }); // Update the value without emitting events this.value = this.inputValues.join(''); + this.previousInputValues = [...this.inputValues]; } /** @@ -525,19 +527,12 @@ export class InputOTP implements ComponentInterface { } /** - * Handles keyboard navigation and input for the OTP component. + * Handles keyboard navigation for the OTP component. * * Navigation: * - Backspace: Clears current input and moves to previous box if empty * - Arrow Left/Right: Moves focus between input boxes * - Tab: Allows normal tab navigation between components - * - * Input Behavior: - * - Validates input against the allowed pattern - * - When entering a key in a filled box: - * - Shifts existing values right if there is room - * - Updates the value of the input group - * - Prevents default behavior to avoid automatic focus shift */ private onKeyDown = (index: number) => (event: KeyboardEvent) => { const { length } = this; @@ -595,34 +590,32 @@ export class InputOTP implements ComponentInterface { // Let all tab events proceed normally return; } - - // If the input box contains a value and the key being - // entered is a valid key for the input box update the value - // and shift the values to the right if there is room. - if (this.inputValues[index] && this.validKeyPattern.test(event.key)) { - if (!this.inputValues[length - 1]) { - for (let i = length - 1; i > index; i--) { - this.inputValues[i] = this.inputValues[i - 1]; - this.inputRefs[i].value = this.inputValues[i] || ''; - } - } - this.inputValues[index] = event.key; - this.inputRefs[index].value = event.key; - this.updateValue(event); - - // Prevent default to avoid the browser from - // automatically moving the focus to the next input - event.preventDefault(); - } }; + /** + * Processes all input scenarios for each input box. + * + * This function manages: + * 1. Autofill handling + * 2. Input validation + * 3. Full selection replacement or typing in an empty box + * 4. Inserting in the middle with available space (shifting) + * 5. Single character replacement + */ private onInput = (index: number) => (event: InputEvent) => { const { length, validKeyPattern } = this; - const value = (event.target as HTMLInputElement).value; - - // If the value is longer than 1 character (autofill), split it into - // characters and filter out invalid ones - if (value.length > 1) { + const input = event.target as HTMLInputElement; + const value = input.value; + const previousValue = this.previousInputValues[index] || ''; + + // 1. Autofill handling + // If the length of the value increases by more than 1 from the previous + // value, treat this as autofill. This is to prevent the case where the + // user is typing a single character into an input box containing a value + // as that will trigger this function with a value length of 2 characters. + const isAutofill = value.length - previousValue.length > 1; + if (isAutofill) { + // Distribute valid characters across input boxes const validChars = value .split('') .filter((char) => validKeyPattern.test(char)) @@ -639,8 +632,10 @@ export class InputOTP implements ComponentInterface { }); } - // Update the value of the input group and emit the input change event - this.value = validChars.join(''); + for (let i = 0; i < length; i++) { + this.inputValues[i] = validChars[i] || ''; + this.inputRefs[i].value = validChars[i] || ''; + } this.updateValue(event); // Focus the first empty input box or the last input box if all boxes @@ -651,23 +646,85 @@ export class InputOTP implements ComponentInterface { this.inputRefs[nextIndex]?.focus(); }, 20); + this.previousInputValues = [...this.inputValues]; return; } - // Only allow input if it matches the pattern - if (value.length > 0 && !validKeyPattern.test(value)) { - this.inputRefs[index].value = ''; - this.inputValues[index] = ''; + // 2. Input validation + // If the character entered is invalid (does not match the pattern), + // restore the previous value and exit + if (value.length > 0 && !validKeyPattern.test(value[value.length - 1])) { + input.value = this.inputValues[index] || ''; + this.previousInputValues = [...this.inputValues]; return; } - // For single character input, fill the current box - this.inputValues[index] = value; - this.updateValue(event); - - if (value.length > 0) { + // 3. Full selection replacement or typing in an empty box + // If the user selects all text in the input box and types, or if the + // input box is empty, replace only this input box. If the box is empty, + // move to the next box, otherwise stay focused on this box. + const isAllSelected = input.selectionStart === 0 && input.selectionEnd === value.length; + const isEmpty = !this.inputValues[index]; + if (isAllSelected || isEmpty) { + this.inputValues[index] = value; + input.value = value; + this.updateValue(event); this.focusNext(index); + this.previousInputValues = [...this.inputValues]; + return; } + + // 4. Inserting in the middle with available space (shifting) + // If typing in a filled input box and there are empty boxes at the end, + // shift all values starting at the current box to the right, and insert + // the new character at the current box. + const hasAvailableBoxAtEnd = this.inputValues[this.inputValues.length - 1] === ''; + if (this.inputValues[index] && hasAvailableBoxAtEnd && value.length === 2) { + // Get the inserted character (from event or by diffing value/previousValue) + let newChar = (event as InputEvent).data; + if (!newChar) { + newChar = value.split('').find((c, i) => c !== previousValue[i]) || value[value.length - 1]; + } + // Validate the new character before shifting + if (!validKeyPattern.test(newChar)) { + input.value = this.inputValues[index] || ''; + this.previousInputValues = [...this.inputValues]; + return; + } + // Shift values right from the end to the insertion point + for (let i = this.inputValues.length - 1; i > index; i--) { + this.inputValues[i] = this.inputValues[i - 1]; + this.inputRefs[i].value = this.inputValues[i] || ''; + } + this.inputValues[index] = newChar; + this.inputRefs[index].value = newChar; + this.updateValue(event); + this.previousInputValues = [...this.inputValues]; + return; + } + + // 5. Single character replacement + // Handles replacing a single character in a box containing a value based + // on the cursor position. We need the cursor position to determine which + // character was the last character typed. For example, if the user types "2" + // in an input box with the cursor at the beginning of the value of "6", + // the value will be "26", but we want to grab the "2" as the last character + // typed. + const cursorPos = input.selectionStart ?? value.length; + const newCharIndex = cursorPos - 1; + const newChar = value[newCharIndex] ?? value[0]; + + // Check if the new character is valid before updating the value + if (!validKeyPattern.test(newChar)) { + input.value = this.inputValues[index] || ''; + this.previousInputValues = [...this.inputValues]; + return; + } + + this.inputValues[index] = newChar; + input.value = newChar; + this.updateValue(event); + this.previousInputValues = [...this.inputValues]; }; /** @@ -711,12 +768,8 @@ export class InputOTP implements ComponentInterface { // Focus the next empty input after pasting // If all boxes are filled, focus the last input - const nextEmptyIndex = validChars.length; - if (nextEmptyIndex < length) { - inputRefs[nextEmptyIndex]?.focus(); - } else { - inputRefs[length - 1]?.focus(); - } + const nextEmptyIndex = validChars.length < length ? validChars.length : length - 1; + inputRefs[nextEmptyIndex]?.focus(); }; /** diff --git a/core/src/components/input-otp/test/basic/input-otp.e2e.ts b/core/src/components/input-otp/test/basic/input-otp.e2e.ts index 2a50c1abd5c..2067a000209 100644 --- a/core/src/components/input-otp/test/basic/input-otp.e2e.ts +++ b/core/src/components/input-otp/test/basic/input-otp.e2e.ts @@ -442,6 +442,67 @@ configs({ modes: ['ios'] }).forEach(({ title, config }) => { await verifyInputValues(inputOtp, ['1', '9', '3', '']); }); + + test('should replace the last value when typing one more than the length', async ({ page }) => { + await page.setContent(`Description`, config); + + const inputOtp = page.locator('ion-input-otp'); + const firstInput = inputOtp.locator('input').first(); + await firstInput.focus(); + + await page.keyboard.type('12345'); + + await verifyInputValues(inputOtp, ['1', '2', '3', '5']); + }); + + test('should replace the last value when typing one more than the length and the type is text', async ({ + page, + }, testInfo) => { + testInfo.annotations.push({ + type: 'issue', + description: 'https://github.com/ionic-team/ionic-framework/issues/30459', + }); + + await page.setContent(`Description`, config); + + const inputOtp = page.locator('ion-input-otp'); + const firstInput = inputOtp.locator('input').first(); + await firstInput.focus(); + + await page.keyboard.type('abcde'); + + await verifyInputValues(inputOtp, ['a', 'b', 'c', 'e']); + }); + + test('should not insert or shift when typing an invalid character before a number', async ({ page }) => { + await page.setContent(`Description`, config); + + const inputOtp = page.locator('ion-input-otp'); + const firstInput = inputOtp.locator('input').first(); + await firstInput.focus(); + + // Move cursor to the start of the first input + await firstInput.evaluate((el: HTMLInputElement) => el.setSelectionRange(0, 0)); + + await page.keyboard.type('w'); + + await verifyInputValues(inputOtp, ['1', '2', '', '']); + }); + + test('should not insert or shift when typing an invalid character after a number', async ({ page }) => { + await page.setContent(`Description`, config); + + const inputOtp = page.locator('ion-input-otp'); + const firstInput = inputOtp.locator('input').first(); + await firstInput.focus(); + + // Move cursor to the end of the first input + await firstInput.evaluate((el: HTMLInputElement) => el.setSelectionRange(1, 1)); + + await page.keyboard.type('w'); + + await verifyInputValues(inputOtp, ['1', '2', '', '']); + }); }); test.describe(title('input-otp: autofill functionality'), () => { @@ -460,6 +521,53 @@ configs({ modes: ['ios'] }).forEach(({ title, config }) => { await expect(lastInput).toBeFocused(); }); + test('should handle autofill correctly when all characters are the same', async ({ page }) => { + await page.setContent(`Description`, config); + + const firstInput = page.locator('ion-input-otp input').first(); + await firstInput.focus(); + + await simulateAutofill(firstInput, '1111'); + + const inputOtp = page.locator('ion-input-otp'); + await verifyInputValues(inputOtp, ['1', '1', '1', '1']); + + const lastInput = page.locator('ion-input-otp input').last(); + await expect(lastInput).toBeFocused(); + }); + + test('should handle autofill correctly when length is 2', async ({ page }) => { + await page.setContent(`Description`, config); + + const firstInput = page.locator('ion-input-otp input').first(); + await firstInput.focus(); + + await simulateAutofill(firstInput, '12'); + + const inputOtp = page.locator('ion-input-otp'); + await verifyInputValues(inputOtp, ['1', '2']); + + const lastInput = page.locator('ion-input-otp input').last(); + await expect(lastInput).toBeFocused(); + }); + + test('should handle autofill correctly when length is 2 after typing 1 character', async ({ page }) => { + await page.setContent(`Description`, config); + + await page.keyboard.type('1'); + + const secondInput = page.locator('ion-input-otp input').nth(1); + await secondInput.focus(); + + await simulateAutofill(secondInput, '22'); + + const inputOtp = page.locator('ion-input-otp'); + await verifyInputValues(inputOtp, ['2', '2']); + + const lastInput = page.locator('ion-input-otp input').last(); + await expect(lastInput).toBeFocused(); + }); + test('should handle autofill correctly when it exceeds the length', async ({ page }) => { await page.setContent(`Description`, config); From 5ce5f7d2ffa92d1556490a33cf0c463aeb20ca1b Mon Sep 17 00:00:00 2001 From: Maria Hutt Date: Sun, 13 Jul 2025 14:06:47 -0700 Subject: [PATCH 06/13] chore(assign-issues): remove maria until OOO ends (#30542) Issue number: N/A --------- ## What is the current behavior? Maria is still getting assigned to issues during her out of office (OOO). ## What is the new behavior? - Removed her until she returns. ## Does this introduce a breaking change? - [ ] Yes - [x] No ## Other information N/A --- .github/workflows/assign-issues.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/assign-issues.yml b/.github/workflows/assign-issues.yml index d06c1f52e10..4608d2323dd 100644 --- a/.github/workflows/assign-issues.yml +++ b/.github/workflows/assign-issues.yml @@ -13,6 +13,6 @@ jobs: - name: 'Auto-assign issue' uses: pozil/auto-assign-issue@39c06395cbac76e79afc4ad4e5c5c6db6ecfdd2e # v2.2.0 with: - assignees: brandyscarney, thetaPC, ShaneK + assignees: brandyscarney, ShaneK numOfAssignee: 1 allowSelfAssign: false From b3b93c15728ac6c692c51e07ff54e314244329bd Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Mon, 14 Jul 2025 13:47:49 -0400 Subject: [PATCH 07/13] chore(deps): update mcr.microsoft.com/playwright docker tag to v1.53.2 (#30523) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This PR contains the following updates: | Package | Type | Update | Change | |---|---|---|---| | mcr.microsoft.com/playwright | final | patch | `v1.53.1` -> `v1.53.2` | --- ### Configuration 📅 **Schedule**: Branch creation - "every weekday before 11am" (UTC), Automerge - At any time (no schedule defined). 🚦 **Automerge**: Disabled by config. Please merge this manually once you are satisfied. ♻ **Rebasing**: Never, or you tick the rebase/retry checkbox. 🔕 **Ignore**: Close this PR and you won't be reminded about this update again. --- - [ ] If you want to rebase/retry this PR, check this box --- This PR was generated by [Mend Renovate](https://mend.io/renovate/). View the [repository job log](https://developer.mend.io/github/ionic-team/ionic-framework). Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- core/Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/core/Dockerfile b/core/Dockerfile index 50bce82ff07..6410906615b 100644 --- a/core/Dockerfile +++ b/core/Dockerfile @@ -1,5 +1,5 @@ # Get Playwright -FROM mcr.microsoft.com/playwright:v1.53.1 +FROM mcr.microsoft.com/playwright:v1.53.2 # Set the working directory WORKDIR /ionic From 850338cbd5c76addbc2cc3068b93071dea14c0af Mon Sep 17 00:00:00 2001 From: Shane Date: Mon, 14 Jul 2025 10:55:45 -0700 Subject: [PATCH 08/13] fix(modal): dismiss modal when parent element is removed from DOM (#30544) Issue number: resolves #30389 --------- ## What is the current behavior? Currently, when the element an ion-modal was presented from is removed, the modal stays presented and can be broken depending on the framework. This is unlike #30540, where children of open modals were being kept open. In this case, specifically the DOM element is being removed for whatever reason and the modal is staying open. ## What is the new behavior? We're now identifying our parent component on load and watching it with a mutation observer to determine if it gets removed from the DOM. If it does, we trigger a dismiss. This, conveniently, works nicely with #30540 and will dismiss all children and grandchildren as well. ## Does this introduce a breaking change? - [ ] Yes - [X] No ## Other information The issue this resolves was already marked closed, but on closer inspection I determined that was a mistake. I believed this issue was related to another one I was dealing with and it is, but it wasn't quite the same. After this issue is merged, I believe we will have handled all avenues of possibly ending up with broken modals because of parent elements or modals being removed. [Relevant Test Page](https://ionic-framework-git-fix-remove-modal-when-parent-removed-ionic1.vercel.app/src/components/modal/test/inline) **Current dev build:** ``` 8.6.5-dev.11752329407.10f7fc80 ``` --- core/src/components/modal/modal.tsx | 70 +++++++++ .../components/modal/test/inline/index.html | 17 +- .../components/modal/test/inline/modal.e2e.ts | 147 ++++++++++++++++++ 3 files changed, 232 insertions(+), 2 deletions(-) diff --git a/core/src/components/modal/modal.tsx b/core/src/components/modal/modal.tsx index e5e906204f8..7843a479611 100644 --- a/core/src/components/modal/modal.tsx +++ b/core/src/components/modal/modal.tsx @@ -96,6 +96,11 @@ export class Modal implements ComponentInterface, OverlayInterface { private viewTransitionAnimation?: Animation; private resizeTimeout?: any; + // Mutation observer to watch for parent removal + private parentRemovalObserver?: MutationObserver; + // Cached original parent from before modal is moved to body during presentation + private cachedOriginalParent?: HTMLElement; + lastFocus?: HTMLElement; animation?: Animation; @@ -398,6 +403,7 @@ export class Modal implements ComponentInterface, OverlayInterface { disconnectedCallback() { this.triggerController.removeClickListener(); this.cleanupViewTransitionListener(); + this.cleanupParentRemovalObserver(); } componentWillLoad() { @@ -407,6 +413,11 @@ export class Modal implements ComponentInterface, OverlayInterface { const attributesToInherit = ['aria-label', 'role']; this.inheritedAttributes = inheritAttributes(el, attributesToInherit); + // Cache original parent before modal gets moved to body during presentation + if (el.parentNode) { + this.cachedOriginalParent = el.parentNode as HTMLElement; + } + /** * When using a controller modal you can set attributes * using the htmlAttributes property. Since the above attributes @@ -642,6 +653,9 @@ export class Modal implements ComponentInterface, OverlayInterface { // Initialize view transition listener for iOS card modals this.initViewTransitionListener(); + // Initialize parent removal observer + this.initParentRemovalObserver(); + unlock(); } @@ -847,6 +861,7 @@ export class Modal implements ComponentInterface, OverlayInterface { this.gesture.destroy(); } this.cleanupViewTransitionListener(); + this.cleanupParentRemovalObserver(); } this.currentBreakpoint = undefined; this.animation = undefined; @@ -1150,6 +1165,61 @@ export class Modal implements ComponentInterface, OverlayInterface { }); } + private initParentRemovalObserver() { + if (typeof MutationObserver === 'undefined') { + return; + } + + // Only observe if we have a cached parent and are in browser environment + if (typeof window === 'undefined' || !this.cachedOriginalParent) { + return; + } + + // Don't observe document or fragment nodes as they can't be "removed" + if ( + this.cachedOriginalParent.nodeType === Node.DOCUMENT_NODE || + this.cachedOriginalParent.nodeType === Node.DOCUMENT_FRAGMENT_NODE + ) { + return; + } + + this.parentRemovalObserver = new MutationObserver((mutations) => { + mutations.forEach((mutation) => { + if (mutation.type === 'childList' && mutation.removedNodes.length > 0) { + // Check if our cached original parent was removed + const cachedParentWasRemoved = Array.from(mutation.removedNodes).some((node) => { + const isDirectMatch = node === this.cachedOriginalParent; + const isContainedMatch = this.cachedOriginalParent + ? (node as HTMLElement).contains?.(this.cachedOriginalParent) + : false; + return isDirectMatch || isContainedMatch; + }); + + // Also check if parent is no longer connected to DOM + const cachedParentDisconnected = this.cachedOriginalParent && !this.cachedOriginalParent.isConnected; + + if (cachedParentWasRemoved || cachedParentDisconnected) { + this.dismiss(undefined, 'parent-removed'); + // Release the reference to the cached original parent + // so we don't have a memory leak + this.cachedOriginalParent = undefined; + } + } + }); + }); + + // Observe document body with subtree to catch removals at any level + this.parentRemovalObserver.observe(document.body, { + childList: true, + subtree: true, + }); + } + + private cleanupParentRemovalObserver() { + this.parentRemovalObserver?.disconnect(); + this.parentRemovalObserver = undefined; + } + render() { const { handle, diff --git a/core/src/components/modal/test/inline/index.html b/core/src/components/modal/test/inline/index.html index 2e29f756b93..40a8eadb1a9 100644 --- a/core/src/components/modal/test/inline/index.html +++ b/core/src/components/modal/test/inline/index.html @@ -22,9 +22,8 @@ - -