Skip to content

Commit be47888

Browse files
committed
fix(modal): dismiss modal when parent element is removed from DOM
1 parent 8b4023d commit be47888

File tree

3 files changed

+145
-2
lines changed

3 files changed

+145
-2
lines changed

core/src/components/modal/modal.tsx

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,11 @@ export class Modal implements ComponentInterface, OverlayInterface {
9696
private viewTransitionAnimation?: Animation;
9797
private resizeTimeout?: any;
9898

99+
// Mutation observer to watch for parent removal
100+
private parentRemovalObserver?: MutationObserver;
101+
// Cached original parent from before modal is moved to body during presentation
102+
private cachedOriginalParent?: HTMLElement;
103+
99104
lastFocus?: HTMLElement;
100105
animation?: Animation;
101106

@@ -398,6 +403,7 @@ export class Modal implements ComponentInterface, OverlayInterface {
398403
disconnectedCallback() {
399404
this.triggerController.removeClickListener();
400405
this.cleanupViewTransitionListener();
406+
this.cleanupParentRemovalObserver();
401407
}
402408

403409
componentWillLoad() {
@@ -407,6 +413,11 @@ export class Modal implements ComponentInterface, OverlayInterface {
407413
const attributesToInherit = ['aria-label', 'role'];
408414
this.inheritedAttributes = inheritAttributes(el, attributesToInherit);
409415

416+
// Cache original parent before modal gets moved to body during presentation
417+
if (el.parentNode) {
418+
this.cachedOriginalParent = el.parentNode as HTMLElement;
419+
}
420+
410421
/**
411422
* When using a controller modal you can set attributes
412423
* using the htmlAttributes property. Since the above attributes
@@ -642,6 +653,9 @@ export class Modal implements ComponentInterface, OverlayInterface {
642653
// Initialize view transition listener for iOS card modals
643654
this.initViewTransitionListener();
644655

656+
// Initialize parent removal observer
657+
this.initParentRemovalObserver();
658+
645659
unlock();
646660
}
647661

@@ -847,6 +861,7 @@ export class Modal implements ComponentInterface, OverlayInterface {
847861
this.gesture.destroy();
848862
}
849863
this.cleanupViewTransitionListener();
864+
this.cleanupParentRemovalObserver();
850865
}
851866
this.currentBreakpoint = undefined;
852867
this.animation = undefined;
@@ -1150,6 +1165,58 @@ export class Modal implements ComponentInterface, OverlayInterface {
11501165
});
11511166
}
11521167

1168+
private initParentRemovalObserver() {
1169+
// Only observe if we have a cached parent and are in browser environment
1170+
if (typeof window === 'undefined' || !this.cachedOriginalParent) {
1171+
return;
1172+
}
1173+
1174+
// Don't observe document or fragment nodes as they can't be "removed"
1175+
if (this.cachedOriginalParent.nodeType === Node.DOCUMENT_NODE || this.cachedOriginalParent.nodeType === Node.DOCUMENT_FRAGMENT_NODE) {
1176+
return;
1177+
}
1178+
1179+
const grandParent = this.cachedOriginalParent.parentNode;
1180+
if (!grandParent) {
1181+
return;
1182+
}
1183+
1184+
this.parentRemovalObserver = new MutationObserver((mutations) => {
1185+
mutations.forEach((mutation) => {
1186+
if (mutation.type === 'childList' && mutation.removedNodes.length > 0) {
1187+
// Check if our cached original parent was removed
1188+
const cachedParentWasRemoved = Array.from(mutation.removedNodes).some(
1189+
(node) => {
1190+
const isDirectMatch = node === this.cachedOriginalParent;
1191+
const isContainedMatch = this.cachedOriginalParent ? (node as HTMLElement).contains?.(this.cachedOriginalParent) : false;
1192+
return isDirectMatch || isContainedMatch;
1193+
}
1194+
);
1195+
1196+
// Also check if parent is no longer connected to DOM
1197+
const cachedParentDisconnected = this.cachedOriginalParent && !this.cachedOriginalParent.isConnected;
1198+
1199+
if (cachedParentWasRemoved || cachedParentDisconnected) {
1200+
this.dismiss(undefined, 'parent-removed');
1201+
}
1202+
}
1203+
});
1204+
});
1205+
1206+
// Observe with subtree to catch nested removals
1207+
this.parentRemovalObserver.observe(grandParent, {
1208+
childList: true,
1209+
subtree: true,
1210+
});
1211+
}
1212+
1213+
private cleanupParentRemovalObserver() {
1214+
if (this.parentRemovalObserver) {
1215+
this.parentRemovalObserver.disconnect();
1216+
this.parentRemovalObserver = undefined;
1217+
}
1218+
}
1219+
11531220
render() {
11541221
const {
11551222
handle,

core/src/components/modal/test/inline/index.html

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,9 +22,8 @@
2222
</ion-header>
2323

2424
<ion-content class="ion-padding">
25-
<button id="open-inline-modal" onclick="openModal(event)">Open Modal</button>
26-
2725
<div id="modal-container">
26+
<button id="open-inline-modal" onclick="openModal(event)">Open Modal</button>
2827
<ion-modal swipe-to-close="true">
2928
<ion-header>
3029
<ion-toolbar>
@@ -34,6 +33,7 @@
3433
<ion-content class="ion-padding">
3534
<p>This is my inline modal content!</p>
3635
<button id="open-child-modal" onclick="openChildModal(event)">Open Child Modal</button>
36+
<button id="remove-modal-container" onclick="removeModalContainer(event)">Remove Modal Container</button>
3737

3838
<ion-modal id="child-modal" swipe-to-close="true">
3939
<ion-header>
@@ -46,6 +46,7 @@
4646
<p>When the parent modal is dismissed, this child modal should also be dismissed automatically.</p>
4747
<button id="dismiss-parent" onclick="dismissParent(event)">Dismiss Parent Modal</button>
4848
<button id="dismiss-child" onclick="dismissChild(event)">Dismiss Child Modal</button>
49+
<button id="child-remove-modal-container" onclick="removeModalContainer(event)">Remove Modal Container</button>
4950
</ion-content>
5051
</ion-modal>
5152
</ion-content>
@@ -78,6 +79,14 @@
7879
childModal.isOpen = false;
7980
};
8081

82+
const removeModalContainer = () => {
83+
const container = document.querySelector('#modal-container');
84+
if (container) {
85+
container.remove();
86+
console.log('Modal container removed from DOM');
87+
}
88+
};
89+
8190
modal.addEventListener('didDismiss', () => {
8291
modal.isOpen = false;
8392
});

core/src/components/modal/test/inline/modal.e2e.ts

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -122,5 +122,72 @@ configs({ modes: ['ios'], directions: ['ltr'] }).forEach(({ title, config }) =>
122122
await modal.evaluate((el: HTMLIonModalElement) => el.firstElementChild!.firstElementChild!.className)
123123
).not.toContain('ion-page');
124124
});
125+
126+
test('it should dismiss modal when parent container is removed from DOM', async ({ page }) => {
127+
await page.goto('/src/components/modal/test/inline', config);
128+
const ionModalDidPresent = await page.spyOnEvent('ionModalDidPresent');
129+
const ionModalDidDismiss = await page.spyOnEvent('ionModalDidDismiss');
130+
131+
const modal = page.locator('ion-modal').first();
132+
const modalContainer = page.locator('#modal-container');
133+
134+
// Open the modal
135+
await page.click('#open-inline-modal');
136+
await ionModalDidPresent.next();
137+
await expect(modal).toBeVisible();
138+
139+
// Remove the modal container from DOM
140+
await page.click('#remove-modal-container');
141+
142+
// Wait for modal to be dismissed
143+
const dismissEvent = await ionModalDidDismiss.next();
144+
145+
// Verify the modal was dismissed with the correct role
146+
expect(dismissEvent.detail.role).toBe('parent-removed');
147+
148+
// Verify the modal is no longer visible
149+
await expect(modal).toBeHidden();
150+
151+
// Verify the container was actually removed
152+
await expect(modalContainer).not.toBeAttached();
153+
});
154+
155+
test('it should dismiss both parent and child modals when parent container is removed from DOM', async ({ page }) => {
156+
await page.goto('/src/components/modal/test/inline', config);
157+
const ionModalDidPresent = await page.spyOnEvent('ionModalDidPresent');
158+
const ionModalDidDismiss = await page.spyOnEvent('ionModalDidDismiss');
159+
160+
const parentModal = page.locator('ion-modal').first();
161+
const childModal = page.locator('#child-modal');
162+
const modalContainer = page.locator('#modal-container');
163+
164+
// Open the parent modal
165+
await page.click('#open-inline-modal');
166+
await ionModalDidPresent.next();
167+
await expect(parentModal).toBeVisible();
168+
169+
// Open the child modal
170+
await page.click('#open-child-modal');
171+
await ionModalDidPresent.next();
172+
await expect(childModal).toBeVisible();
173+
174+
// Remove the modal container from DOM
175+
await page.click('#child-remove-modal-container');
176+
177+
// Wait for both modals to be dismissed
178+
const firstDismissEvent = await ionModalDidDismiss.next();
179+
const secondDismissEvent = await ionModalDidDismiss.next();
180+
181+
// Verify at least one modal was dismissed with 'parent-removed' role
182+
const dismissRoles = [firstDismissEvent.detail.role, secondDismissEvent.detail.role];
183+
expect(dismissRoles).toContain('parent-removed');
184+
185+
// Verify both modals are no longer visible
186+
await expect(parentModal).toBeHidden();
187+
await expect(childModal).toBeHidden();
188+
189+
// Verify the container was actually removed
190+
await expect(modalContainer).not.toBeAttached();
191+
});
125192
});
126193
});

0 commit comments

Comments
 (0)