From 9b0099f462fda6d40b49dde1a1c97afbbbee2287 Mon Sep 17 00:00:00 2001 From: Shane Date: Fri, 11 Jul 2025 12:01:59 -0700 Subject: [PATCH] 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 8f528e658e..e5e906204f 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 726b682bd8..2e29f756b9 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 05276722d9..35690fc2d8 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',