From be4788838ad36a8b6d041583941487f867243f19 Mon Sep 17 00:00:00 2001 From: ShaneK Date: Sat, 12 Jul 2025 04:21:52 -0700 Subject: [PATCH 1/6] fix(modal): dismiss modal when parent element is removed from DOM --- core/src/components/modal/modal.tsx | 67 +++++++++++++++++++ .../components/modal/test/inline/index.html | 13 +++- .../components/modal/test/inline/modal.e2e.ts | 67 +++++++++++++++++++ 3 files changed, 145 insertions(+), 2 deletions(-) diff --git a/core/src/components/modal/modal.tsx b/core/src/components/modal/modal.tsx index e5e906204f8..33ff95ca060 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,58 @@ export class Modal implements ComponentInterface, OverlayInterface { }); } + private initParentRemovalObserver() { + // 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; + } + + const grandParent = this.cachedOriginalParent.parentNode; + if (!grandParent) { + 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'); + } + } + }); + }); + + // Observe with subtree to catch nested removals + this.parentRemovalObserver.observe(grandParent, { + childList: true, + subtree: true, + }); + } + + private cleanupParentRemovalObserver() { + if (this.parentRemovalObserver) { + 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..dcdccbbe0aa 100644 --- a/core/src/components/modal/test/inline/index.html +++ b/core/src/components/modal/test/inline/index.html @@ -22,9 +22,8 @@ - -