Skip to content

Commit 12f678c

Browse files
committed
WIP: attempt to use MutationObserver to detach overlay
1 parent d4cccb7 commit 12f678c

File tree

2 files changed

+47
-57
lines changed

2 files changed

+47
-57
lines changed

src/cdk/overlay/overlay-ref.ts

Lines changed: 35 additions & 53 deletions
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,7 @@
66
* found in the LICENSE file at https://angular.dev/license
77
*/
88

9-
import {Direction, Directionality} from '../bidi';
10-
import {ComponentPortal, Portal, PortalOutlet, TemplatePortal} from '../portal';
9+
import {Location} from '@angular/common';
1110
import {
1211
AfterRenderRef,
1312
ComponentRef,
@@ -16,19 +15,17 @@ import {
1615
NgZone,
1716
Renderer2,
1817
afterNextRender,
19-
afterRender,
20-
untracked,
2118
} from '@angular/core';
22-
import {Location} from '@angular/common';
23-
import {Observable, Subject, merge, SubscriptionLike, Subscription} from 'rxjs';
24-
import {takeUntil} from 'rxjs/operators';
19+
import {Observable, Subject, Subscription, SubscriptionLike} from 'rxjs';
20+
import {Direction, Directionality} from '../bidi';
21+
import {coerceArray, coerceCssPixelValue} from '../coercion';
22+
import {ComponentPortal, Portal, PortalOutlet, TemplatePortal} from '../portal';
23+
import {BackdropRef} from './backdrop-ref';
2524
import {OverlayKeyboardDispatcher} from './dispatchers/overlay-keyboard-dispatcher';
2625
import {OverlayOutsideClickDispatcher} from './dispatchers/overlay-outside-click-dispatcher';
2726
import {OverlayConfig} from './overlay-config';
28-
import {coerceCssPixelValue, coerceArray} from '../coercion';
2927
import {PositionStrategy} from './position/position-strategy';
3028
import {ScrollStrategy} from './scroll';
31-
import {BackdropRef} from './backdrop-ref';
3229

3330
/** An object where all of its properties cannot be written. */
3431
export type ImmutableObject<T> = {
@@ -60,10 +57,6 @@ export class OverlayRef implements PortalOutlet {
6057
/** Stream of mouse outside events dispatched to this overlay. */
6158
readonly _outsidePointerEvents = new Subject<MouseEvent>();
6259

63-
private _renders = new Subject<void>();
64-
65-
private _afterRenderRef: AfterRenderRef;
66-
6760
/** Reference to the currently-running `afterNextRender` call. */
6861
private _afterNextRenderRef: AfterRenderRef | undefined;
6962

@@ -87,18 +80,6 @@ export class OverlayRef implements PortalOutlet {
8780
}
8881

8982
this._positionStrategy = _config.positionStrategy;
90-
91-
// Users could open the overlay from an `effect`, in which case we need to
92-
// run the `afterRender` as `untracked`. We don't recommend that users do
93-
// this, but we also don't want to break users who are doing it.
94-
this._afterRenderRef = untracked(() =>
95-
afterRender(
96-
() => {
97-
this._renders.next();
98-
},
99-
{injector: this._injector},
100-
),
101-
);
10283
}
10384

10485
/** The overlay's HTML element */
@@ -182,6 +163,7 @@ export class OverlayRef implements PortalOutlet {
182163

183164
// Only emit the `attachments` event once all other setup is done.
184165
this._attachments.next();
166+
this._detachWhenEmptyMutationObserver?.disconnect();
185167

186168
// Track this overlay by the keyboard dispatcher
187169
this._keyboardDispatcher.add(this);
@@ -242,6 +224,7 @@ export class OverlayRef implements PortalOutlet {
242224

243225
// Only emit after everything is detached.
244226
this._detachments.next();
227+
this._detachWhenEmptyMutationObserver?.disconnect();
245228

246229
// Remove this overlay from keyboard dispatcher tracking.
247230
this._keyboardDispatcher.remove(this);
@@ -281,8 +264,7 @@ export class OverlayRef implements PortalOutlet {
281264
}
282265

283266
this._detachments.complete();
284-
this._afterRenderRef.destroy();
285-
this._renders.complete();
267+
this._detachWhenEmptyMutationObserver?.disconnect();
286268
}
287269

288270
/** Whether the overlay has attached content. */
@@ -488,34 +470,34 @@ export class OverlayRef implements PortalOutlet {
488470
}
489471
}
490472

473+
private _detachWhenEmptyMutationObserver: MutationObserver | null = null;
474+
475+
private _detachContent() {
476+
// Needs a couple of checks for the pane and host, because
477+
// they may have been removed by the time the zone stabilizes.
478+
if (!this._pane || !this._host || this._pane.children.length === 0) {
479+
if (this._pane && this._config.panelClass) {
480+
this._toggleClasses(this._pane, this._config.panelClass, false);
481+
}
482+
483+
if (this._host && this._host.parentElement) {
484+
this._previousHostParent = this._host.parentElement;
485+
this._host.remove();
486+
}
487+
488+
this._detachWhenEmptyMutationObserver?.disconnect();
489+
}
490+
}
491+
491492
/** Detaches the overlay content next time the zone stabilizes. */
492493
private _detachContentWhenEmpty() {
493-
// Normally we wouldn't have to explicitly run this outside the `NgZone`, however
494-
// if the consumer is using `zone-patch-rxjs`, the `Subscription.unsubscribe` call will
495-
// be patched to run inside the zone, which will throw us into an infinite loop.
496-
this._ngZone.runOutsideAngular(() => {
497-
// We can't remove the host here immediately, because the overlay pane's content
498-
// might still be animating. This stream helps us avoid interrupting the animation
499-
// by waiting for the pane to become empty.
500-
const subscription = this._renders
501-
.pipe(takeUntil(merge(this._attachments, this._detachments)))
502-
.subscribe(() => {
503-
// Needs a couple of checks for the pane and host, because
504-
// they may have been removed by the time the zone stabilizes.
505-
if (!this._pane || !this._host || this._pane.children.length === 0) {
506-
if (this._pane && this._config.panelClass) {
507-
this._toggleClasses(this._pane, this._config.panelClass, false);
508-
}
509-
510-
if (this._host && this._host.parentElement) {
511-
this._previousHostParent = this._host.parentElement;
512-
this._host.remove();
513-
}
514-
515-
subscription.unsubscribe();
516-
}
517-
});
518-
});
494+
if (globalThis.MutationObserver && this._pane) {
495+
this._detachWhenEmptyMutationObserver ||= new globalThis.MutationObserver(() => {
496+
this._detachContent();
497+
});
498+
this._detachWhenEmptyMutationObserver.observe(this._pane, {childList: true});
499+
}
500+
this._detachContent();
519501
}
520502

521503
/** Disposes of a scroll strategy. */

src/cdk/overlay/overlay.spec.ts

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,3 @@
1-
import {Direction, Directionality} from '../bidi';
2-
import {CdkPortal, ComponentPortal, TemplatePortal} from '../portal';
31
import {Location} from '@angular/common';
42
import {SpyLocation} from '@angular/common/testing';
53
import {
@@ -21,6 +19,8 @@ import {
2119
waitForAsync,
2220
} from '@angular/core/testing';
2321
import {NoopAnimationsModule} from '@angular/platform-browser/animations';
22+
import {Direction, Directionality} from '../bidi';
23+
import {CdkPortal, ComponentPortal, TemplatePortal} from '../portal';
2424
import {dispatchFakeEvent} from '../testing/private';
2525
import {
2626
Overlay,
@@ -380,8 +380,9 @@ describe('Overlay', () => {
380380
expect(overlayRef.getDirection()).toBe('ltr');
381381
});
382382

383-
it('should add and remove the overlay host as the ref is being attached and detached', () => {
383+
it('should add and remove the overlay host as the ref is being attached and detached', async () => {
384384
const overlayRef = overlay.create();
385+
const pane = overlayContainerElement.querySelector('.cdk-overlay-pane') as HTMLElement;
385386

386387
overlayRef.attach(componentPortal);
387388
viewContainerFixture.detectChanges();
@@ -390,13 +391,16 @@ describe('Overlay', () => {
390391
.withContext('Expected host element to be in the DOM.')
391392
.toBeTruthy();
392393

394+
pane.appendChild(document.createElement('div'));
393395
overlayRef.detach();
394396

395397
expect(overlayRef.hostElement.parentElement)
396398
.withContext('Expected host element not to have been removed immediately.')
397399
.toBeTruthy();
398400

399401
viewContainerFixture.detectChanges();
402+
pane.children[0].remove();
403+
await new Promise(r => setTimeout(r));
400404

401405
expect(overlayRef.hostElement.parentElement)
402406
.withContext('Expected host element to have been removed once the zone stabilizes.')
@@ -922,7 +926,7 @@ describe('Overlay', () => {
922926
expect(pane.classList).toContain('custom-class-two');
923927
});
924928

925-
it('should remove the custom panel class when the overlay is detached', () => {
929+
it('should remove the custom panel class when the overlay is detached', async () => {
926930
const config = new OverlayConfig({panelClass: 'custom-panel-class'});
927931
const overlayRef = overlay.create(config);
928932

@@ -957,12 +961,16 @@ describe('Overlay', () => {
957961
.withContext('Expected class to be added')
958962
.toContain('custom-panel-class');
959963

964+
// Simulate animating element that hasn't been removed yet.
965+
pane.appendChild(document.createElement('div'));
960966
overlayRef.detach();
961967
expect(pane.classList)
962968
.withContext('Expected class not to be removed immediately')
963969
.toContain('custom-panel-class');
964970
await viewContainerFixture.whenStable();
965971

972+
pane.children[0].remove();
973+
await new Promise(r => setTimeout(r));
966974
expect(pane.classList)
967975
.not.withContext('Expected class to be removed on stable')
968976
.toContain('custom-panel-class');

0 commit comments

Comments
 (0)