From 850338cbd5c76addbc2cc3068b93071dea14c0af Mon Sep 17 00:00:00 2001 From: Shane Date: Mon, 14 Jul 2025 10:55:45 -0700 Subject: [PATCH] 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 e5e906204f..7843a47961 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 2e29f756b9..40a8eadb1a 100644 --- a/core/src/components/modal/test/inline/index.html +++ b/core/src/components/modal/test/inline/index.html @@ -22,9 +22,8 @@ - -